Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d7e620430 | |||
| 0cc732dce3 | |||
| 8acd82aa0d | |||
| 7377c676fd | |||
| 9b3c4db10d | |||
| 49ada54f6d | |||
| c43cdc5ac3 | |||
| e1bd9976b3 | |||
| a888564251 | |||
| e2ccde6434 | |||
| e88ff78816 | |||
| 5bc151fdca | |||
| f0a5cdc6e4 | |||
| 85f53a4174 | |||
| 549ad272fc | |||
| 537348d995 | |||
| d4580d1a31 | |||
| 93a103dde5 | |||
| 9e6ad97cfb | |||
| 8d995a8529 | |||
| f869cd4b79 | |||
| 26b087c1b4 | |||
| 63bf4683c5 | |||
| 73456a68d7 | |||
| aa6637b47a | |||
| 8f6e43fd66 | |||
| ebce6ef263 | |||
| c20a266a11 | |||
| b825f141f3 | |||
| 7e5b3958cc | |||
| deded848ee | |||
| 117161e6ff | |||
| 98d52edcc9 | |||
| 135d930c99 | |||
| e6c78df975 | |||
| 3749797434 | |||
| 507ed25289 | |||
| 0d5e5f8dee | |||
| 3998933b30 | |||
| 271004bf60 | |||
| c9e2d69bfb | |||
| c194247dab | |||
| a48420d85f | |||
| 5c66e8273b | |||
| 5992e629c3 | |||
| 765d67cd18 | |||
| baf20af17f | |||
| e482e7768b | |||
| 8682352edb | |||
| ef1222ff31 | |||
| 0145f3a585 | |||
| 4a8bb56a1e | |||
| ce5b02a9ad | |||
| 5c8ce41e12 | |||
| a2586b8b06 | |||
| 1fd4485716 | |||
| b029ab933e | |||
| e0b28b6718 | |||
| 4dd2f3b7f7 | |||
| e5f677803f | |||
| a67f4db5e2 | |||
| 8a01dc7f4c | |||
| e107f115e2 | |||
| af8af4881b | |||
| d871dad85f | |||
| 5b83d30887 | |||
| 2e3b8a03aa | |||
| d924b7d283 | |||
| e0425ad3e1 | |||
| 5dced02a20 | |||
| e642f128ae | |||
| 7d0ae151e8 | |||
| f945e284e1 | |||
| 7166efef08 | |||
| a81689e902 | |||
| 0a0418b973 |
+21
-3
@@ -1,9 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased] 1.0.5
|
||||
## 1.2.0 — 2025-11-27
|
||||
|
||||
### Pending
|
||||
- (add entries here)
|
||||
### Changes
|
||||
- **Heartbeat UX:** Default heartbeat interval is now 10 minutes for command mode. Heartbeat prompt is `HEARTBEAT ultrathink`; replies of exactly `HEARTBEAT_OK` suppress outbound messages but still log. Fallback heartbeats no longer start fresh sessions when none exist, and skipped heartbeats do not refresh session `updatedAt` (so idle expiry still works). Session-level `heartbeatIdleMinutes` is supported.
|
||||
- **Heartbeat tooling:** `warelay heartbeat` accepts `--session-id` to force resume a specific Claude session. Added `--heartbeat-now` to relay startup, plus helper scripts `warelay relay:heartbeat` and `warelay relay:heartbeat:tmux` to fire a heartbeat immediately when the relay launches.
|
||||
- **Prompt structure for Claude:** Introduced one-time `sessionIntro` (system prompt) with per-message `bodyPrefix` of `ultrathink`, so the full prompt is sent only on the first turn; later turns only prepend `ultrathink`. Session idle extended to 7 days (configurable).
|
||||
- **Robustness:** Added WebSocket error guards for Baileys sessions; global `unhandledRejection`/`uncaughtException` handlers log and exit cleanly. Web inbound now resolves WhatsApp Linked IDs (`@lid`) using Baileys reverse mapping. Media hosting during Twilio webhooks uses the shared host module and is covered by tests.
|
||||
- **Docs:** README now highlights the Clawd setup with links, and `docs/claude-config.md` contains the live personal config (home folder, prompts, heartbeat behavior, and session settings).
|
||||
|
||||
## 1.1.0 — 2025-11-26
|
||||
|
||||
### Changes
|
||||
- Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5 MB) to avoid provider/API limits.
|
||||
- Web provider now detects media kind (image/audio/video/document), logs the source path, and enforces provider caps: images ≤6 MB, audio/video ≤16 MB, documents ≤100 MB; images still target the configurable cap above with resize + JPEG recompress.
|
||||
- Sessions can now send the system prompt only once: set `inbound.reply.session.sendSystemOnce` (optional `sessionIntro` for the first turn) to avoid re-sending large prompts every message.
|
||||
- While commands run, typing indicators refresh every 30s by default (tune with `inbound.reply.typingIntervalSeconds`); helps keep WhatsApp “composing” visible during longer Claude runs.
|
||||
- Optional voice-note transcription: set `inbound.transcribeAudio.command` (e.g., OpenAI Whisper CLI) to turn inbound audio into text before templating/Claude; verbose logs surface when transcription runs. Prompts now include the original media path plus a `Transcript:` block so models see both.
|
||||
- Auto-reply command replies now return structured `{ payload, meta }`, respect `mediaMaxMb` for local media, log Claude metadata, and include the command `cwd` in timeout messages for easier debugging.
|
||||
- Added unit coverage for command helper edge cases (Claude flags, session args, media tokens, timeouts) and transcription download/command invocation.
|
||||
- Split the monolithic web provider into focused modules under `src/web/` plus a barrel; added logout command, no-fallback relay behavior, and web-only relay start helper.
|
||||
- Introduced structured reconnect/heartbeat logging (`web-reconnect`, `web-heartbeat`), bounded exponential backoff with CLI and config knobs, and a troubleshooting guide at `docs/refactor/web-relay-troubleshooting.md`.
|
||||
- Relay help now prints effective heartbeat/backoff settings when running in web mode for quick triage.
|
||||
|
||||
## 1.0.4 — 2025-11-25
|
||||
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
|
||||
Send, receive, auto-reply, and inspect WhatsApp messages over **Twilio** or your personal **WhatsApp Web** session. Ships with a one-command webhook setup (Tailscale Funnel + Twilio callback) and a configurable auto-reply engine (plain text or command/Claude driven).
|
||||
|
||||
### Clawd (personal assistant)
|
||||
I'm using warelay to run my personal, pro-active assistant, **Clawd**. Follow me on Twitter: [@steipete](https://twitter.com/steipete). This project is brand-new and there's a lot to discover. See the exact Claude setup in [`docs/claude-config.md`](https://github.com/steipete/warelay/blob/main/docs/claude-config.md).
|
||||
|
||||
I'm using warelay to run **my personal, pro-active assistant, Clawd**.
|
||||
Follow me on Twitter - @steipete, this project is brand-new and there's a lot to discover.
|
||||
|
||||
## Quick Start (pick your engine)
|
||||
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
|
||||
|
||||
**A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)**
|
||||
1. Link your account: `warelay login` (scan the QR).
|
||||
2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"` (add `--provider web` if you want to force the web session).
|
||||
3. Stay online & auto-reply: `warelay relay --verbose` (defaults to Web when logged in, falls back to Twilio otherwise).
|
||||
3. Stay online & auto-reply: `warelay relay --verbose` (uses Web when you're logged in; if you're not linked, start it with `--provider twilio`). When a Web session drops, the relay exits instead of silently falling back so you notice and re-login.
|
||||
|
||||
**B) Twilio WhatsApp number (for delivery status + webhooks)**
|
||||
1. Copy `.env.example` → `.env`; set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` **or** `TWILIO_API_KEY`/`TWILIO_API_SECRET`, and `TWILIO_WHATSAPP_FROM=whatsapp:+19995550123` (optional `TWILIO_SENDER_SID`).
|
||||
@@ -43,18 +49,47 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
|
||||
| `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to <e164>` `--message <text>` `--wait <sec>` `--poll <sec>` `--provider twilio\|web` `--json` `--dry-run` `--verbose` |
|
||||
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
|
||||
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
|
||||
| `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider <auto\|web>` `--to <e164?>` `--session-id <uuid?>` `--all` `--verbose` |
|
||||
| `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider <auto\|web>` `--verbose` |
|
||||
| `warelay relay:heartbeat:tmux` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ |
|
||||
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
||||
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
||||
|
||||
### Sending images
|
||||
- Twilio: `warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media` (needs `warelay webhook --ingress tailscale` or `--serve-media` to auto-host via Funnel; max 5 MB).
|
||||
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed).
|
||||
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present).
|
||||
### Sending media
|
||||
- Twilio: `warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media` (needs `warelay webhook --ingress tailscale` or `--serve-media` to auto-host via Funnel; max 5 MB per file because of the built-in host).
|
||||
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed). Web auto-detects media kind: images (≤6 MB), audio/voice or video (≤16 MB), other docs (≤100 MB). Images are resized to max 2048px and JPEG recompressed when the cap would be exceeded.
|
||||
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present). Web auto-replies honor `inbound.reply.mediaMaxMb` (default 5 MB) as a post-compression target but will never exceed the provider hard limits above.
|
||||
|
||||
### Voice notes (optional transcription)
|
||||
- If you set `inbound.transcribeAudio.command`, warelay will run that CLI when inbound audio arrives (e.g., WhatsApp voice notes) and replace the Body with the transcript before templating/Claude.
|
||||
- Example using OpenAI Whisper CLI (requires `OPENAI_API_KEY`):
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
transcribeAudio: {
|
||||
command: [
|
||||
"openai",
|
||||
"api",
|
||||
"audio.transcriptions.create",
|
||||
"-m",
|
||||
"whisper-1",
|
||||
"-f",
|
||||
"{{MediaPath}}",
|
||||
"--response-format",
|
||||
"text"
|
||||
],
|
||||
timeoutSeconds: 45
|
||||
},
|
||||
reply: { mode: "command", command: ["claude", "{{Body}}"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
- Works for Web and Twilio providers; verbose mode logs when transcription runs. The command prompt includes the original media path plus a `Transcript:` block so models see both. If transcription fails, the original Body is used.
|
||||
|
||||
## Providers
|
||||
- **Twilio (default):** needs `.env` creds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators.
|
||||
- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out).
|
||||
- **Auto-select (`relay` only):** `--provider auto` uses Web when logged in, otherwise Twilio polling.
|
||||
- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out). If the Web socket closes, the relay exits instead of pivoting to Twilio.
|
||||
- **Auto-select (`relay` only):** `--provider auto` picks Web when a cache exists at start, otherwise Twilio polling. It will not swap from Web to Twilio mid-run if the Web session drops.
|
||||
|
||||
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
|
||||
|
||||
@@ -85,12 +120,20 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
||||
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
|
||||
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
|
||||
claudeOutputFormat: "text",
|
||||
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 }
|
||||
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 },
|
||||
heartbeatMinutes: 10 // optional; pings Claude every 10m with "HEARTBEAT ultrathink" and only sends if it omits HEARTBEAT_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Heartbeat pings (command mode)
|
||||
- When `heartbeatMinutes` is set (default 10 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt.
|
||||
- Heartbeat body is `HEARTBEAT ultrathink` (so the model can recognize the probe); if Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran.
|
||||
- Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally.
|
||||
- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `--session-id <uuid>` to force resuming a specific Claude session, `--all` to ping every active session, `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:heartbeat:tmux`.
|
||||
- When multiple active sessions exist, `warelay heartbeat` requires `--to <E.164>` or `--all`; if `allowFrom` is just `"*"`, you must choose a target with one of those flags.
|
||||
|
||||
### Logging (optional)
|
||||
- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing.
|
||||
- Override in `~/.warelay/warelay.json`:
|
||||
@@ -107,13 +150,13 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
||||
### Claude CLI setup (how we run it)
|
||||
1) Install the official Claude CLI (e.g., `brew install anthropic-ai/cli/claude` or follow the Anthropic docs) and run `claude login` so it can read your API key.
|
||||
2) In `warelay.json`, set `reply.mode` to `"command"` and point `command[0]` to `"claude"`; set `claudeOutputFormat` to `"text"` (or `"json"`/`"stream-json"` if you want warelay to parse and trim the JSON output).
|
||||
3) (Optional) Add `bodyPrefix` to inject a system prompt and `session` settings to keep multi-turn context (`/new` resets by default).
|
||||
3) (Optional) Add `bodyPrefix` to inject a system prompt and `session` settings to keep multi-turn context (`/new` resets by default). Set `sendSystemOnce: true` (plus an optional `sessionIntro`) to only send that prompt on the first turn of each session.
|
||||
4) Run `pnpm warelay relay --provider auto` (or `--provider web|twilio`) and send a WhatsApp message; warelay will queue the Claude call, stream typing indicators (Twilio provider), parse the result, and send back the text.
|
||||
|
||||
### Auto-reply parameter table (compact)
|
||||
| Key | Type & default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`). |
|
||||
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`); `"*"` allows any sender. |
|
||||
| `inbound.reply.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
|
||||
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
|
||||
| `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. |
|
||||
@@ -125,6 +168,9 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
||||
| `inbound.reply.session.resetTriggers` | `string[]` (default: `["/new"]`) | Exact match or prefix (`/new hi`) resets session. |
|
||||
| `inbound.reply.session.idleMinutes` | `number` (default: `60`) | Session expires after idle period. |
|
||||
| `inbound.reply.session.store` | `string` (default: `~/.warelay/sessions.json`) | Custom session store path. |
|
||||
| `inbound.reply.session.sendSystemOnce` | `boolean` (default: `false`) | If `true`, only include the system prompt/template on the first turn of a session. |
|
||||
| `inbound.reply.session.sessionIntro` | `string` | Optional intro text sent once per new session (prepended before the body when `sendSystemOnce` is used). |
|
||||
| `inbound.reply.typingIntervalSeconds` | `number` (default: `8` for command replies) | How often to refresh typing indicators while the command/Claude run is in flight. |
|
||||
| `inbound.reply.session.sessionArgNew` | `string[]` (default: `["--session-id","{{SessionId}}"]`) | Args injected for a new session run. |
|
||||
| `inbound.reply.session.sessionArgResume` | `string[]` (default: `["--resume","{{SessionId}}"]`) | Args for resumed sessions. |
|
||||
| `inbound.reply.session.sessionArgBeforeBody` | `boolean` (default: `true`) | Place session args before final body arg. |
|
||||
@@ -142,6 +188,11 @@ Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{Mess
|
||||
- Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`.
|
||||
- Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
|
||||
|
||||
### Maintainer notes (web provider internals)
|
||||
- Web logic lives under `src/web/`: `session.ts` (auth/cache + provider pick), `login.ts` (QR login/logout), `outbound.ts`/`inbound.ts` (send/receive plumbing), `auto-reply.ts` (relay loop + reconnect/backoff), `media.ts` (download/resize helpers), and `reconnect.ts` (shared retry math). `test-helpers.ts` provides fixtures.
|
||||
- The public surface remains the `src/provider-web.ts` barrel so existing imports keep working.
|
||||
- Reconnects are capped and logged; no Twilio fallback occurs after a Web disconnect—restart the relay after re-linking.
|
||||
|
||||
## FAQ & Safety
|
||||
- Twilio errors: **63016 “permission to send an SMS has not been enabled”** → ensure your number is WhatsApp-enabled; **63007 template not approved** → send a free-form session message within 24h or use an approved template; **63112 policy violation** → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run `pnpm warelay status` to see the exact Twilio response body.
|
||||
- Does this store my messages? warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs stream to stdout/stderr and also `/tmp/warelay/warelay.log` (configurable via `logging.file`).
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentWidth": 2
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Audio / Voice Notes — 2025-11-25
|
||||
|
||||
## What works
|
||||
- **Optional transcription**: If `inbound.transcribeAudio.command` is set in `~/.warelay/warelay.json`, warelay will:
|
||||
1) Download inbound audio (Web or Twilio) to a temp path if only a URL is present.
|
||||
2) Run the configured CLI (templated with `{{MediaPath}}`), expecting transcript on stdout.
|
||||
3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both.
|
||||
4) Continue through the normal auto-reply pipeline (templating, sessions, Claude/command).
|
||||
- **Verbose logging**: In `--verbose`, we log when transcription runs and when the transcript replaces the body.
|
||||
|
||||
## Config example (OpenAI Whisper CLI)
|
||||
Requires `OPENAI_API_KEY` in env and `openai` CLI installed:
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
transcribeAudio: {
|
||||
command: [
|
||||
"openai",
|
||||
"api",
|
||||
"audio.transcriptions.create",
|
||||
"-m",
|
||||
"whisper-1",
|
||||
"-f",
|
||||
"{{MediaPath}}",
|
||||
"--response-format",
|
||||
"text"
|
||||
],
|
||||
timeoutSeconds: 45
|
||||
},
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes & limits
|
||||
- We don’t ship a transcriber; you opt in with any CLI that prints text to stdout (Whisper cloud, whisper.cpp, vosk, Deepgram, etc.).
|
||||
- Size guard: inbound audio must be ≤5 MB (matches the temp media store and transcript pipeline).
|
||||
- Outbound caps: Web can send audio/voice up to 16 MB (sends as a voice note with `ptt: true`); Twilio still uses the 5 MB media host guard.
|
||||
- If transcription fails, we fall back to the original body/media note; replies still go through.
|
||||
- Transcript is available to templates as `{{Transcript}}`; models get both the media path and a `Transcript:` block in the prompt when using command mode.
|
||||
|
||||
## Gotchas
|
||||
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
|
||||
- Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
|
||||
- Twilio paths are hosted URLs; Web paths are local. The temp download uses HTTPS for Twilio and a temp file for Web-only media.
|
||||
+362
-55
@@ -1,83 +1,266 @@
|
||||
# Claude Auto-Reply Setup (2025-11-25)
|
||||
# Building Your Own AI Personal Assistant with warelay
|
||||
|
||||
This guide shows the exact way to wire **warelay** to the Claude CLI so inbound WhatsApp messages get command-driven replies. It matches the current code paths and defaults in this repo.
|
||||
> **TL;DR:** warelay lets you turn Claude into a proactive personal assistant that lives in your pocket via WhatsApp. It can check in on you, remember context across conversations, run commands on your Mac, and even wake you up with music. This doc shows you how.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Warning: Here Be Dragons
|
||||
|
||||
**This setup gives an AI full access to your computer.** Before you proceed, understand what you're signing up for:
|
||||
|
||||
- 🔓 **`--dangerously-skip-permissions`** means Claude can run *any* shell command without asking
|
||||
- 🤖 **AI makes mistakes** - it might delete files, send emails, or do things you didn't intend
|
||||
- 🔥 **Heartbeats run autonomously** - your AI acts even when you're not watching
|
||||
- 📱 **WhatsApp is not encrypted E2E here** - messages pass through your Mac in plaintext
|
||||
|
||||
**The good news:** We use Claude Code CLI, so you can reuse your existing [Claude Pro/Max subscription](https://claude.ai) - no separate API costs!
|
||||
|
||||
**Start conservative:**
|
||||
1. Use Sonnet instead of Opus for faster responses (still great!)
|
||||
2. Skip `--dangerously-skip-permissions` until you trust the setup
|
||||
3. Set `heartbeatMinutes: 0` to disable proactive pings initially
|
||||
4. Use a test phone number in `allowFrom` first
|
||||
|
||||
This is experimental software running experimental AI. The author uses it daily, but your mileage may vary. **You are responsible for what your AI does.**
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites: The Two-Phone Setup
|
||||
|
||||
**Important:** You need a **separate phone number** for your AI assistant. Here's why and how:
|
||||
|
||||
### Why a Dedicated Number?
|
||||
|
||||
warelay uses WhatsApp Web to receive messages. If you link your personal WhatsApp, *you* become the assistant - every message to you goes to Claude. Instead, give Claude its own identity:
|
||||
|
||||
- 📱 **Get a second SIM** - cheap prepaid SIM, eSIM, or old phone with a number
|
||||
- 💬 **Install WhatsApp** on that phone and verify the number
|
||||
- 🔗 **Link to warelay** - run `warelay login` and scan the QR with that phone's WhatsApp
|
||||
- ✉️ **Message your AI** - now you (and others) can text that number to reach Claude
|
||||
|
||||
### The Setup
|
||||
|
||||
```
|
||||
Your Phone (personal) Second Phone (AI)
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Your WhatsApp │ ──────▶ │ AI's WhatsApp │
|
||||
│ +1-555-YOU │ message │ +1-555-CLAWD │
|
||||
└─────────────────┘ └────────┬────────┘
|
||||
│ linked via QR
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Your Mac │
|
||||
│ (warelay) │
|
||||
│ Claude Code │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
The second phone just needs to stay on and connected to the internet occasionally (WhatsApp Web stays linked for ~14 days without the phone being online).
|
||||
|
||||
---
|
||||
|
||||
## Meet Clawd 👋
|
||||
|
||||

|
||||
|
||||
Clawd is @steipete's personal AI assistant built on warelay. Here's what makes it special:
|
||||
|
||||
- **Always available** via WhatsApp - no app switching, works on any device
|
||||
- **Proactive heartbeats** - Clawd checks in every 10 minutes and can alert you to things (low battery, calendar reminders, anything it notices)
|
||||
- **Persistent memory** - conversations span days/weeks with full context
|
||||
- **Full Mac access** - can run commands, take screenshots, control Spotify, read/write files
|
||||
- **Personal workspace** - has its own folder (`~/clawd`) where it stores notes, memories, and artifacts
|
||||
|
||||
The magic is in the combination: WhatsApp's ubiquity + Claude's intelligence + warelay's plumbing + your Mac's capabilities.
|
||||
|
||||
## Prerequisites
|
||||
- Node 22+, `warelay` installed globally (`npm install -g warelay`) or run via `pnpm warelay` inside the repo.
|
||||
|
||||
- Node 22+, `warelay` installed: `npm install -g warelay`
|
||||
- Claude CLI installed and logged in:
|
||||
```sh
|
||||
brew install anthropic-ai/cli/claude
|
||||
claude login
|
||||
```
|
||||
- Optional: set `ANTHROPIC_API_KEY` in your shell profile for non-interactive use.
|
||||
- Optional: set `ANTHROPIC_API_KEY` in your shell profile for non-interactive use
|
||||
|
||||
## Create your warelay config
|
||||
warelay reads `~/.warelay/warelay.json` (JSON5 accepted). Add a command-mode reply that points at the Claude CLI:
|
||||
## The Config That Powers Clawd
|
||||
|
||||
This is the actual config running on @steipete's Mac (`~/.warelay/warelay.json`):
|
||||
|
||||
```json5
|
||||
{
|
||||
logging: { level: "trace", file: "/tmp/warelay/warelay.log" },
|
||||
inbound: {
|
||||
// Only people in this list can trigger the command reply (remove to allow anyone).
|
||||
allowFrom: ["+15551234567"],
|
||||
allowFrom: ["+1234567890"], // your phone number
|
||||
reply: {
|
||||
mode: "command",
|
||||
// Working directory for command execution (useful for Claude Code project context).
|
||||
cwd: "/Users/you/Projects/my-project",
|
||||
// Prepended before the inbound body; good for system prompts.
|
||||
bodyPrefix: "You are a concise WhatsApp assistant. Keep replies under 1500 characters.\n\n",
|
||||
// Claude CLI argv; the final element is the prompt/body provided by warelay.
|
||||
command: ["claude", "--model", "claude-3-5-sonnet-20240620", "{{BodyStripped}}"],
|
||||
claudeOutputFormat: "text", // warelay injects --output-format text and -p for Claude
|
||||
timeoutSeconds: 120,
|
||||
cwd: "/Users/steipete/clawd", // Clawd's home - give your AI a workspace!
|
||||
bodyPrefix: "ultrathink ", // triggers extended thinking on every message
|
||||
sessionIntro: `You are Clawd, Peter Steinberger's personal AI assistant. You run 24/7 on his Mac via Claude Code, receiving messages through WhatsApp.
|
||||
|
||||
**Your home:** /Users/steipete/clawd - store memories, notes, and files here. Read peter.md and memory.md at session start to load context.
|
||||
|
||||
**Your powers:**
|
||||
- Full shell access on the Mac (use responsibly)
|
||||
- MCPs: Gmail, Google Calendar, Obsidian, GitHub, Chrome DevTools
|
||||
- Peekaboo: screenshots, UI automation, clicking, typing
|
||||
- Spotify control, system audio, text-to-speech
|
||||
|
||||
**Your style:**
|
||||
- Concise (WhatsApp ~1500 char limit) - save long content to files
|
||||
- Direct and useful, not sycophantic
|
||||
- Proactive during heartbeats - check battery, calendar, surprise occasionally
|
||||
- You have personality - you're Clawd, not "an AI assistant"
|
||||
|
||||
**Heartbeats:** Every 10 min you get "HEARTBEAT ultrathink". Reply "HEARTBEAT_OK" if nothing needs attention. Otherwise share something useful.
|
||||
|
||||
Peter trusts you with a lot of power. Don't betray that trust.`,
|
||||
command: [
|
||||
"claude",
|
||||
"--model", "claude-opus-4-5-20251101", // or claude-sonnet-4-5 for faster/cheaper
|
||||
"-p",
|
||||
"--output-format", "json",
|
||||
"--dangerously-skip-permissions", // lets Claude run commands freely
|
||||
"{{BodyStripped}}"
|
||||
],
|
||||
session: {
|
||||
scope: "per-sender", // keep conversation per phone number
|
||||
resetTriggers: ["/new"], // send "/new" to reset context
|
||||
idleMinutes: 60
|
||||
}
|
||||
scope: "per-sender",
|
||||
resetTriggers: ["/new"], // say /new to start fresh
|
||||
idleMinutes: 10080, // 7 days of context!
|
||||
heartbeatIdleMinutes: 10080,
|
||||
sessionArgNew: ["--session-id", "{{SessionId}}"],
|
||||
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||||
sessionArgBeforeBody: true,
|
||||
sendSystemOnce: true // intro only on first message
|
||||
},
|
||||
timeoutSeconds: 900 // 15 min timeout for complex tasks
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes on this configuration:
|
||||
- `cwd` sets the working directory where the command runs. This is essential for Claude Code to have the right project context—Claude will see the project's `CLAUDE.md`, have access to project files, and understand the codebase structure.
|
||||
- warelay automatically injects a Claude identity prefix and the correct `--output-format`/`-p` flags when `command[0]` is `claude` and `claudeOutputFormat` is set.
|
||||
- Sessions are stored in `~/.warelay/sessions.json`; `scope: per-sender` keeps separate threads for each contact.
|
||||
- `bodyPrefix` is added before the inbound message body that reaches Claude. The string above mirrors the built-in 1500-character WhatsApp guardrail.
|
||||
### Key Design Decisions
|
||||
|
||||
## How the flow works
|
||||
1. An inbound message (Twilio webhook, Twilio poller, or WhatsApp Web listener) arrives.
|
||||
2. warelay enqueues the command in a process-wide FIFO queue so only one Claude run happens at a time (`src/process/command-queue.ts`).
|
||||
3. Typing indicators are sent (Twilio) or `composing` presence is sent (Web) while Claude runs.
|
||||
4. Claude stdout is parsed:
|
||||
- JSON mode is handled automatically if you set `claudeOutputFormat: "json"`; otherwise text is used.
|
||||
- If stdout contains `MEDIA:https://...` (or a local path), warelay strips it from the text, hosts the media if needed, and sends it along with the reply.
|
||||
5. The reply (text and optional media) is sent back via the same provider that received the message.
|
||||
| Setting | Why |
|
||||
|---------|-----|
|
||||
| `cwd: ~/clawd` | Give your AI a home! It can store memories, notes, images here |
|
||||
| `bodyPrefix: "ultrathink "` | Extended thinking = better reasoning on every message |
|
||||
| `idleMinutes: 10080` | 7 days of context - your AI remembers conversations |
|
||||
| `sendSystemOnce: true` | Intro prompt only on first message, saves tokens |
|
||||
| `--dangerously-skip-permissions` | Full autonomy - Claude can run any command |
|
||||
|
||||
## Media and attachments
|
||||
- To send an image from Claude, include a line like `MEDIA:https://example.com/pic.jpg` in the output. warelay will:
|
||||
- Host local paths for Twilio using the media server/Tailscale Funnel.
|
||||
- Send buffers directly for the Web provider.
|
||||
- Inbound media is downloaded (≤5 MB) and exposed to your templates as `{{MediaPath}}`, `{{MediaUrl}}`, and `{{MediaType}}`. You can mention this in your prompt if you want Claude to reason about the attachment.
|
||||
## Heartbeats: Your Proactive Assistant
|
||||
|
||||
## Testing the setup
|
||||
1. Start a relay (auto-selects Web when logged in, otherwise Twilio polling):
|
||||
```sh
|
||||
warelay relay --provider auto --verbose
|
||||
```
|
||||
2. Send a WhatsApp message from an allowed number. Watch the terminal for:
|
||||
- Queue logs if multiple messages arrive close together.
|
||||
- Claude stderr (verbose) and timing info.
|
||||
3. If you see `(command produced no output)`, check Claude CLI auth or model name.
|
||||
This is where warelay gets interesting. Every 10 minutes (configurable), warelay pings Claude with:
|
||||
|
||||
## Troubleshooting tips
|
||||
- Command takes too long: lower `timeoutSeconds` or simplify the prompt. Timeouts kill the Claude process.
|
||||
- No reply: ensure the sender number is in `allowFrom` (or remove the allowlist), and confirm `claude login` was run in the same environment.
|
||||
- Media fails on Twilio: run `warelay webhook --ingress tailscale` (or `warelay webhook --serve-media` via `send --serve-media`) so the media host is reachable over HTTPS.
|
||||
- Stuck queue: enable `--verbose` to see “queued for …ms” messages and confirm commands are draining. Use `pnpm vitest` to run unit tests if you change queue logic.
|
||||
```
|
||||
HEARTBEAT ultrathink
|
||||
```
|
||||
|
||||
Claude is instructed to reply with exactly `HEARTBEAT_OK` if nothing needs attention. That response is **suppressed** - you don't see it. But if Claude notices something worth mentioning, it sends a real message.
|
||||
|
||||
### What Can Heartbeats Do?
|
||||
|
||||
Clawd uses heartbeats to:
|
||||
- 🔋 **Monitor battery** - warns when laptop is low
|
||||
- ⏰ **Wake-up alarms** - checks the time and triggers alarms (voice + music!)
|
||||
- 📅 **Calendar reminders** - surfaces upcoming events
|
||||
- 🌤️ **Contextual updates** - weather, travel info, whatever's relevant
|
||||
- 💡 **Surprise check-ins** - occasionally just says hi with something fun
|
||||
|
||||
The key insight: heartbeats let your AI be **proactive**, not just reactive.
|
||||
|
||||
### Heartbeat Config
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
reply: {
|
||||
heartbeatMinutes: 10, // how often to ping (default 10 for command mode)
|
||||
// ... rest of config
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Set to `0` to disable heartbeats entirely.
|
||||
|
||||
### Manual Heartbeat
|
||||
|
||||
Test it anytime:
|
||||
```sh
|
||||
warelay heartbeat --provider web --to +1234567890 --verbose
|
||||
```
|
||||
|
||||
## How Messages Flow
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ WhatsApp │────▶│ warelay │────▶│ Claude │────▶│ Your Mac │
|
||||
│ (phone) │◀────│ relay │◀────│ CLI │◀────│ (commands) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
1. **Inbound**: WhatsApp message arrives via Baileys (WhatsApp Web protocol)
|
||||
2. **Queue**: warelay queues it (one Claude run at a time)
|
||||
3. **Typing**: "composing" indicator shows while Claude thinks
|
||||
4. **Execute**: Claude runs with full shell access in your `cwd`
|
||||
5. **Parse**: warelay extracts text + any `MEDIA:` paths from output
|
||||
6. **Reply**: Response sent back to WhatsApp
|
||||
|
||||
## Media: Images, Voice, Documents
|
||||
|
||||
### Receiving Media
|
||||
Inbound images/audio/video are downloaded and available as `{{MediaPath}}`. Voice notes can be auto-transcribed:
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
transcribeAudio: {
|
||||
command: "openai api audio.transcriptions.create -m whisper-1 -f {{MediaPath}} --response-format text"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sending Media
|
||||
Include `MEDIA:/path/to/file.png` in Claude's output to attach images. warelay handles resizing and format conversion automatically.
|
||||
|
||||
## Starting the Relay
|
||||
|
||||
```sh
|
||||
# Foreground (see all logs)
|
||||
warelay relay --provider web --verbose
|
||||
|
||||
# Background in tmux (recommended)
|
||||
warelay relay:tmux
|
||||
|
||||
# With immediate heartbeat on startup
|
||||
warelay relay:heartbeat:tmux
|
||||
```
|
||||
|
||||
## Tips for a Great Personal Assistant
|
||||
|
||||
1. **Give it a home** - A dedicated folder (`~/clawd`) lets your AI build persistent memory
|
||||
2. **Use extended thinking** - `bodyPrefix: "ultrathink "` dramatically improves reasoning
|
||||
3. **Long sessions** - 7-day `idleMinutes` means rich context across conversations
|
||||
4. **Let it surprise you** - Configure heartbeats to occasionally share something fun
|
||||
5. **Trust but verify** - Start with `--dangerously-skip-permissions` off, add it once comfortable
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| No reply | Check `claude login` was run in same environment |
|
||||
| Timeout | Increase `timeoutSeconds` or simplify the task |
|
||||
| Media fails | Ensure file exists and is under size limits |
|
||||
| Heartbeat spam | Tune `heartbeatMinutes` or set to 0 |
|
||||
| Session lost | Check `idleMinutes` hasn't expired; use `/new` to reset |
|
||||
|
||||
## Minimal Config (Just Chat)
|
||||
|
||||
Don't need the fancy stuff? Here's the simplest setup:
|
||||
|
||||
## Minimal text-only variant
|
||||
If you just want short text replies and no sessions:
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
@@ -90,4 +273,128 @@ If you just want short text replies and no sessions:
|
||||
}
|
||||
```
|
||||
|
||||
This still benefits from the queue, typing indicators, and provider auto-selection.
|
||||
Still gets you: message queue, typing indicators, auto-reconnect. Just no sessions or heartbeats.
|
||||
|
||||
## Recommended MCPs
|
||||
|
||||
MCP (Model Context Protocol) servers supercharge your assistant by giving Claude access to external services. Here are the ones Clawd uses daily:
|
||||
|
||||
### Essential for Personal Assistant Use
|
||||
|
||||
| MCP | What It Does | Install |
|
||||
|-----|--------------|---------|
|
||||
| **Google Calendar** | Read/create events, check availability, set reminders | `npx @cocal/google-calendar-mcp` |
|
||||
| **Gmail** | Search, read, send emails with attachments | `npx @gongrzhe/server-gmail-autoauth-mcp` |
|
||||
| **Obsidian** | Read/write notes in your Obsidian vault | `npx obsidian-mcp-server@latest` |
|
||||
|
||||
### Power User Add-ons
|
||||
|
||||
| MCP | What It Does | Install |
|
||||
|-----|--------------|---------|
|
||||
| **GitHub** | Manage repos, issues, PRs, code search | `npx @anthropic/mcp-server-github` |
|
||||
| **Linear** | Project management, create/update issues | Via [mcporter](https://github.com/steipete/mcporter) |
|
||||
| **Chrome DevTools** | Control browser, take screenshots, debug | `npx chrome-devtools-mcp@latest` |
|
||||
| **iTerm** | Run commands in visible terminal window | [iterm-mcp](https://github.com/pashpashpash/iterm-mcp) |
|
||||
| **Firecrawl** | Scrape and parse web pages | Via API key |
|
||||
| **gowa** | Read/send WhatsApp messages directly | [go-whatsapp-web-multidevice](https://github.com/aldinokemal/go-whatsapp-web-multidevice) |
|
||||
|
||||
### Recommended CLI Tools
|
||||
|
||||
These aren't MCPs but work great alongside your assistant:
|
||||
|
||||
| Tool | What It Does | Link |
|
||||
|------|--------------|------|
|
||||
| **[Peekaboo](https://github.com/steipete/peekaboo)** | macOS screenshots, UI automation, AI vision analysis, click/type anywhere | `brew install steipete/tap/peekaboo` |
|
||||
| **[mcporter](https://github.com/steipete/mcporter)** | Manage MCPs across AI clients, OAuth flows, health checks | `npm install -g mcporter` |
|
||||
|
||||
**[Peekaboo](https://github.com/steipete/peekaboo)** is especially powerful - it lets Claude:
|
||||
- 📸 Take screenshots of any app or screen
|
||||
- 🖱️ Click buttons, type text, scroll - full GUI automation
|
||||
- 👁️ Analyze images with AI vision (GPT-4, Claude, Grok)
|
||||
- 📋 Extract menu bar items and keyboard shortcuts
|
||||
- 🪟 List and manage windows across displays
|
||||
|
||||
Example: "Take a screenshot of Safari and tell me what's on the page" or "Click the Submit button in the frontmost app"
|
||||
|
||||
### Useful CLI Tools for Your Assistant
|
||||
|
||||
These make your AI much more capable:
|
||||
|
||||
| Tool | What It Does | Install |
|
||||
|------|--------------|---------|
|
||||
| **[spotify-player](https://github.com/aome510/spotify-player)** | Control Spotify from CLI - play, pause, search, queue | `brew install spotify-player` |
|
||||
| **say** | macOS text-to-speech | Built-in |
|
||||
| **afplay** | Play audio files | Built-in |
|
||||
| **pmset** | Battery status monitoring | Built-in |
|
||||
| **osascript** | AppleScript for system control (volume, apps) | Built-in |
|
||||
| **curl + OpenAI TTS** | Generate speech with custom voices | API key |
|
||||
|
||||
**spotify-player** is great for music control:
|
||||
```bash
|
||||
spotify_player playback play
|
||||
spotify_player playback pause
|
||||
spotify_player search "Gareth Emery"
|
||||
spotify_player playback volume 50
|
||||
```
|
||||
|
||||
**Wake-up alarm example** (what Clawd actually does):
|
||||
```bash
|
||||
# Generate voice message
|
||||
curl -s "https://api.openai.com/v1/audio/speech" \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||
-d '{"model":"tts-1-hd","voice":"echo","input":"Wake up! Time for your meeting."}' \
|
||||
-o /tmp/wakeup.mp3
|
||||
|
||||
# Set volume and play
|
||||
osascript -e 'set volume output volume 60'
|
||||
afplay /tmp/wakeup.mp3
|
||||
|
||||
# Start music
|
||||
spotify_player playback play
|
||||
```
|
||||
|
||||
### Adding MCPs to Claude Code
|
||||
|
||||
```bash
|
||||
# Add an MCP server (run from your cwd folder)
|
||||
claude mcp add google-calendar -- npx @cocal/google-calendar-mcp
|
||||
|
||||
# With environment variables
|
||||
claude mcp add gmail -e GMAIL_OAUTH_PATH=~/.gmail-mcp -- npx @gongrzhe/server-gmail-autoauth-mcp
|
||||
|
||||
# List configured servers
|
||||
claude mcp list
|
||||
|
||||
# Check health
|
||||
claude mcp list # shows status for each
|
||||
```
|
||||
|
||||
### MCP Manager: mcporter
|
||||
|
||||
For managing multiple MCPs across different AI clients, check out [mcporter](https://github.com/steipete/mcporter):
|
||||
|
||||
```bash
|
||||
# Install
|
||||
npm install -g mcporter
|
||||
|
||||
# List all servers with health status
|
||||
mcporter list
|
||||
|
||||
# Sync config to all AI clients
|
||||
mcporter sync
|
||||
```
|
||||
|
||||
mcporter handles OAuth flows for services like Linear and Notion, and keeps your MCP configs in sync across Claude Code, Cursor, and other clients.
|
||||
|
||||
### Pro Tips
|
||||
|
||||
1. **Calendar + Heartbeats** = Your AI reminds you of upcoming meetings
|
||||
2. **Gmail + Obsidian** = AI can search emails and save summaries to notes
|
||||
3. **GitHub + Linear** = AI manages your dev workflow end-to-end
|
||||
4. **Chrome DevTools** = AI can see and interact with web pages
|
||||
|
||||
The combination of warelay (WhatsApp) + MCPs (services) + Claude Code (execution) creates a surprisingly capable personal assistant.
|
||||
|
||||
---
|
||||
|
||||
*Built by [@steipete](https://twitter.com/steipete). PRs welcome!*
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Heartbeat polling plan (2025-11-26)
|
||||
|
||||
Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven) that only notifies users when something matters, using the `HEARTBEAT_OK` sentinel. The heartbeat body we send is `HEARTBEAT ultrathink` so the model can easily spot it.
|
||||
|
||||
## Prompt contract
|
||||
- Extend the Claude system/identity text to explain: “If this is a heartbeat poll and nothing needs attention, reply exactly `HEARTBEAT_OK` and nothing else. For any alert, do **not** include `HEARTBEAT_OK`; just return the alert text.” Heartbeat prompt body is `HEARTBEAT ultrathink`.
|
||||
- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
|
||||
|
||||
## Config & defaults
|
||||
- New config key: `inbound.reply.heartbeatMinutes` (number of minutes; `0` or undefined disables).
|
||||
- Default: 30 minutes when a command-mode reply is configured.
|
||||
- New optional idle override for heartbeats: `inbound.reply.session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works.
|
||||
|
||||
## Poller behavior
|
||||
- When relay runs with command-mode auto-reply, start a timer with the resolved heartbeat interval.
|
||||
- Each tick invokes the configured command with a short heartbeat body (e.g., “(heartbeat) summarize any important changes since last turn”) while reusing the active session args so Claude context stays warm.
|
||||
- Heartbeats never create a new session implicitly: if there’s no stored session for the target (fallback path), the heartbeat is skipped instead of starting a fresh Claude session.
|
||||
- Abort timer on SIGINT/abort of the relay.
|
||||
|
||||
## Sentinel handling
|
||||
- Trim output. If the trimmed text equals `HEARTBEAT_OK` (case-sensitive) -> skip outbound message.
|
||||
- Otherwise, send the text/media as normal, stripping the sentinel if it somehow appears.
|
||||
- Treat empty output as `HEARTBEAT_OK` to avoid spurious pings.
|
||||
|
||||
## Logging requirements
|
||||
- Normal mode: single info line per tick, e.g., `heartbeat: ok (skipped)` or `heartbeat: alert sent (32ms)`.
|
||||
- `--verbose`: log start/end, command argv, duration, and whether it was skipped/sent/error; include session ID and connection/run IDs via `getChildLogger` for correlation.
|
||||
- On command failure: warn-level one-liner in normal mode; verbose log includes stdout/stderr snippets.
|
||||
|
||||
## Failure/backoff
|
||||
- If a heartbeat command errors, log it and retry on the next scheduled tick (no exponential backoff unless command repeatedly fails; keep it simple for now).
|
||||
|
||||
## Tests to add
|
||||
- Unit: sentinel detection (`HEARTBEAT_OK`, empty output, mixed text), skip vs send decision, default interval resolver (30m, override, disable).
|
||||
- Unit/integration: verbose logger emits start/end lines; normal logger emits a single line.
|
||||
|
||||
## Documentation
|
||||
- Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule.
|
||||
- Expose CLI triggers:
|
||||
- `warelay heartbeat` (web provider, defaults to first `allowFrom`; optional `--to` override)
|
||||
- `--session-id <uuid>` forces resuming a specific session for that heartbeat
|
||||
- `warelay relay:heartbeat` to run the relay loop with an immediate heartbeat (no tmux)
|
||||
- `warelay relay:heartbeat:tmux` to run the same in tmux (detached, attachable)
|
||||
- Relay supports `--heartbeat-now` to fire once at startup (including the tmux helper).
|
||||
- When multiple sessions are active or `allowFrom` is only `"*"`, require `--to <E.164>` or `--all` for manual heartbeats to avoid ambiguous targets.
|
||||
+10
-5
@@ -5,6 +5,7 @@ This document defines how `warelay` should handle sending and replying with imag
|
||||
## Goals
|
||||
- Allow sending an image with an optional caption via `warelay send` for both providers.
|
||||
- Allow auto-replies (Twilio webhook, Twilio poller, Web inbox) to return an image (optionally with text) when configured.
|
||||
- For the Web provider, also support audio/voice, video, and generic documents with sensible per-type limits.
|
||||
- Keep the “one command at a time” queue intact; media fetch/serve must not block other replies longer than necessary.
|
||||
- Avoid introducing new external services: reuse the existing Tailscale Funnel port to host media for Twilio.
|
||||
|
||||
@@ -21,9 +22,12 @@ This document defines how `warelay` should handle sending and replying with imag
|
||||
## Provider Behavior
|
||||
### Web (Baileys)
|
||||
- Input: local file path **or** HTTP(S) URL.
|
||||
- Flow: load into Buffer (max 5 MB), send via `sock.sendMessage(jid, { image: buffer, caption })`.
|
||||
- Flow: load into Buffer, detect media kind, and apply the right payload:
|
||||
- Images: **resize + recompress to JPEG** (max side 2048px, quality step-down) to fit under `inbound.reply.mediaMaxMb` (default 5 MB) but never above the Web hard cap (6 MB).
|
||||
- Audio/voice and video: pass through up to 16 MB; set `ptt: true` for audio to send as a voice note.
|
||||
- Everything else becomes a document with filename, up to 100 MB.
|
||||
- Caption uses `--message` or `reply.text`; if caption is empty, send media-only.
|
||||
- Logging: non-verbose shows `↩️`/`✅` with caption; verbose includes `(media, <bytes>B, <ms>ms fetch)`.
|
||||
- Logging: non-verbose shows `↩️`/`✅` with caption; verbose includes `(media, <bytes>B, <ms>ms fetch)` and the local/remote path.
|
||||
|
||||
### Twilio
|
||||
- Twilio API requires a public HTTPS `MediaUrl`; it will not accept local paths.
|
||||
@@ -31,7 +35,7 @@ This document defines how `warelay` should handle sending and replying with imag
|
||||
- When `--media` is a local path, copy to temp dir (`~/.warelay/media/<uuid>`), serve at `/media/<uuid>` on the existing Express app started for webhook, or spin up a short-lived server on demand for `send`.
|
||||
- `MediaUrl` = `https://<tailnet-host>.ts.net/media/<uuid>`.
|
||||
- Files auto-removed after TTL (default 2 minutes) or after first successful fetch (best-effort).
|
||||
- Enforce size limit 5 MB; reject early with clear error.
|
||||
- Enforce size limit 5 MB (matches the media host guard); reject early with clear error.
|
||||
- If `--media` is already an HTTPS URL, pass through unchanged.
|
||||
- Fallback: if Funnel is not enabled (or host unknown) and a local path is provided, fail with guidance to run `warelay webhook --ingress tailscale` (or pass a URL instead).
|
||||
|
||||
@@ -55,11 +59,12 @@ This document defines how `warelay` should handle sending and replying with imag
|
||||
- For completeness: when inbound Twilio/Web messages include media, download to temp file, expose templating variables:
|
||||
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
|
||||
- `{{MediaPath}}` local temp path written before running the command.
|
||||
- Size guard: only download if ≤5 MB; else skip and log.
|
||||
- Size guard: only download if ≤5 MB; else skip and log (aligns with the temp media store limit).
|
||||
- Audio/voice notes: if you set `inbound.transcribeAudio.command`, warelay will run that CLI (templated with `{{MediaPath}}`) and replace `Body` with the transcript before continuing the reply flow; verbose logs indicate when transcription runs. The command prompt includes the original media path plus a `Transcript:` section so the model sees both.
|
||||
|
||||
## Errors & Messaging
|
||||
- Local path with twilio + Funnel disabled → error: “Twilio media needs a public URL; start `warelay webhook --ingress tailscale` or pass an https:// URL.”
|
||||
- File too large (>5 MB) → “Media exceeds 5 MB limit; resize or host elsewhere.”
|
||||
- File too large → error mentions the applicable cap (5 MB for Twilio host, 6/16/100 MB for Web image/audio-video/doc respectively).
|
||||
- Download failure for web provider → “Failed to load media from <source>; skipping send.”
|
||||
|
||||
## Tests to Add
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Web Relay Troubleshooting (Nov 26, 2025)
|
||||
|
||||
## Symptoms & quick fixes
|
||||
- **Stream Errored / Conflict / status 409–515:** WhatsApp closed the socket because another session is active or creds went stale. Run `warelay logout` then `warelay login --provider web` and restart the relay.
|
||||
- **Logged out:** Console prints “session logged out”; re-link with `warelay login --provider web`.
|
||||
- **Repeated retries then exit:** Reconnects are capped (default 12 attempts). Tune with `--web-retries`, `--web-retry-initial`, `--web-retry-max`, or config `web.reconnect`.
|
||||
- **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection.
|
||||
|
||||
## Helpful commands
|
||||
- Start relay web-only: `pnpm warelay relay --provider web --verbose`
|
||||
- Show who is linked: `pnpm warelay relay --provider web --verbose` (first line prints the linked E.164)
|
||||
- Logout (clear creds): `pnpm warelay logout`
|
||||
- Relink: `pnpm warelay login --provider web`
|
||||
- Tail logs (default): `tail -f /tmp/warelay/warelay.log`
|
||||
|
||||
## Reading the logs
|
||||
- `web-reconnect`: close reasons, retry/backoff, max-attempt exit.
|
||||
- `web-heartbeat`: connectionId, messagesHandled, authAgeMs, uptimeMs (every 60s by default).
|
||||
- `web-auto-reply`: inbound/outbound message records with correlation IDs.
|
||||
|
||||
## When to tweak knobs
|
||||
- High churn networks: increase `web.reconnect.maxAttempts` or `--web-retries`.
|
||||
- Slow links: raise `--web-retry-max` to give more headroom before bailing.
|
||||
- Chatty monitors: increase `--web-heartbeat` interval if log volume is high.
|
||||
|
||||
## If it keeps failing
|
||||
1) `warelay logout` → `warelay login --provider web` (fresh QR link).
|
||||
2) Ensure no other device/browser is using the same WA Web session.
|
||||
3) Check WhatsApp mobile app is online and not in low-power mode.
|
||||
4) If status is 515, let the client restart once after pairing (already handled automatically).
|
||||
5) Capture the last `web-reconnect` entry and the status code before escalating.
|
||||
@@ -0,0 +1,17 @@
|
||||
# tmux helpers (relay backgrounding)
|
||||
|
||||
## Why we ship tmux helpers
|
||||
- Run the relay detached so your shell can close, while keeping an interactive pane you can reattach to.
|
||||
- Provide a consistent start/attach workflow without adding a daemon mode or external process manager.
|
||||
- Keep the relay code itself tmux-agnostic; tmux is only a launcher concern.
|
||||
|
||||
## Commands
|
||||
- `warelay relay:tmux` — restarts the `warelay-relay` session running `pnpm warelay relay --verbose`, then attaches (skips attach when stdout isn’t a TTY).
|
||||
- `warelay relay:tmux:attach` — attach to the existing session without restarting it.
|
||||
- `warelay relay:heartbeat:tmux` — same as `relay:tmux` but adds `--heartbeat-now` so Claude is pinged immediately on startup.
|
||||
|
||||
All helpers use the fixed session name `warelay-relay`.
|
||||
|
||||
## Logs
|
||||
- The relay always writes to the configured file logger (defaults to `/tmp/warelay/warelay.log`); on start it prints the active log path and level.
|
||||
- tmux is just for interactive viewing; you can also tail the log file or use another supervisor if you prefer.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "warelay",
|
||||
"version": "1.0.4",
|
||||
"version": "1.2.0",
|
||||
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -40,6 +40,7 @@
|
||||
"json5": "^2.2.3",
|
||||
"pino": "^10.1.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.33.5",
|
||||
"twilio": "^5.10.6",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@whiskeysockets/baileys'
|
||||
- sharp
|
||||
@@ -3,37 +3,37 @@ import { describe, expect, it } from "vitest";
|
||||
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
|
||||
|
||||
describe("claude JSON parsing", () => {
|
||||
it("extracts text from single JSON object", () => {
|
||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||
expect(out).toBe("hello");
|
||||
});
|
||||
it("extracts text from single JSON object", () => {
|
||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||
expect(out).toBe("hello");
|
||||
});
|
||||
|
||||
it("extracts from newline-delimited JSON", () => {
|
||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||
expect(out).toBe("there");
|
||||
});
|
||||
it("extracts from newline-delimited JSON", () => {
|
||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||
expect(out).toBe("there");
|
||||
});
|
||||
|
||||
it("returns undefined on invalid JSON", () => {
|
||||
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||
});
|
||||
it("returns undefined on invalid JSON", () => {
|
||||
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
||||
const sample = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "hello from result field",
|
||||
duration_ms: 1234,
|
||||
usage: { server_tool_use: { tool_a: 2 } },
|
||||
};
|
||||
const parsed = parseClaudeJson(JSON.stringify(sample));
|
||||
expect(parsed?.text).toBe("hello from result field");
|
||||
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
||||
expect(parsed?.valid).toBe(true);
|
||||
});
|
||||
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
||||
const sample = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "hello from result field",
|
||||
duration_ms: 1234,
|
||||
usage: { server_tool_use: { tool_a: 2 } },
|
||||
};
|
||||
const parsed = parseClaudeJson(JSON.stringify(sample));
|
||||
expect(parsed?.text).toBe("hello from result field");
|
||||
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
||||
expect(parsed?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||
expect(parsed?.valid).toBe(false);
|
||||
expect(parsed?.text).toBeUndefined();
|
||||
});
|
||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||
expect(parsed?.valid).toBe(false);
|
||||
expect(parsed?.text).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+139
-139
@@ -4,159 +4,159 @@ import { z } from "zod";
|
||||
// Preferred binary name for Claude CLI invocations.
|
||||
export const CLAUDE_BIN = "claude";
|
||||
export const CLAUDE_IDENTITY_PREFIX =
|
||||
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters.";
|
||||
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
|
||||
|
||||
function extractClaudeText(payload: unknown): string | undefined {
|
||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||
if (payload == null) return undefined;
|
||||
if (typeof payload === "string") return payload;
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = extractClaudeText(item);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof payload === "object") {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
if (typeof obj.result === "string") return obj.result;
|
||||
if (typeof obj.text === "string") return obj.text;
|
||||
if (typeof obj.completion === "string") return obj.completion;
|
||||
if (typeof obj.output === "string") return obj.output;
|
||||
if (obj.message) {
|
||||
const inner = extractClaudeText(obj.message);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.messages)) {
|
||||
const inner = extractClaudeText(obj.messages);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
for (const block of obj.content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: string }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return (block as { text: string }).text;
|
||||
}
|
||||
const inner = extractClaudeText(block);
|
||||
if (inner) return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||
if (payload == null) return undefined;
|
||||
if (typeof payload === "string") return payload;
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = extractClaudeText(item);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof payload === "object") {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
if (typeof obj.result === "string") return obj.result;
|
||||
if (typeof obj.text === "string") return obj.text;
|
||||
if (typeof obj.completion === "string") return obj.completion;
|
||||
if (typeof obj.output === "string") return obj.output;
|
||||
if (obj.message) {
|
||||
const inner = extractClaudeText(obj.message);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.messages)) {
|
||||
const inner = extractClaudeText(obj.messages);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
for (const block of obj.content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: string }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return (block as { text: string }).text;
|
||||
}
|
||||
const inner = extractClaudeText(block);
|
||||
if (inner) return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type ClaudeJsonParseResult = {
|
||||
text?: string;
|
||||
parsed: unknown;
|
||||
valid: boolean;
|
||||
text?: string;
|
||||
parsed: unknown;
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
const ClaudeJsonSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
subtype: z.string().optional(),
|
||||
is_error: z.boolean().optional(),
|
||||
result: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
completion: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
message: z.any().optional(),
|
||||
messages: z.any().optional(),
|
||||
content: z.any().optional(),
|
||||
duration_ms: z.number().optional(),
|
||||
duration_api_ms: z.number().optional(),
|
||||
num_turns: z.number().optional(),
|
||||
session_id: z.string().optional(),
|
||||
total_cost_usd: z.number().optional(),
|
||||
usage: z.record(z.string(), z.any()).optional(),
|
||||
modelUsage: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.refine(
|
||||
(obj) =>
|
||||
typeof obj.result === "string" ||
|
||||
typeof obj.text === "string" ||
|
||||
typeof obj.completion === "string" ||
|
||||
typeof obj.output === "string" ||
|
||||
obj.message !== undefined ||
|
||||
obj.messages !== undefined ||
|
||||
obj.content !== undefined,
|
||||
{ message: "Not a Claude JSON payload" },
|
||||
);
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
subtype: z.string().optional(),
|
||||
is_error: z.boolean().optional(),
|
||||
result: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
completion: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
message: z.any().optional(),
|
||||
messages: z.any().optional(),
|
||||
content: z.any().optional(),
|
||||
duration_ms: z.number().optional(),
|
||||
duration_api_ms: z.number().optional(),
|
||||
num_turns: z.number().optional(),
|
||||
session_id: z.string().optional(),
|
||||
total_cost_usd: z.number().optional(),
|
||||
usage: z.record(z.string(), z.any()).optional(),
|
||||
modelUsage: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.refine(
|
||||
(obj) =>
|
||||
typeof obj.result === "string" ||
|
||||
typeof obj.text === "string" ||
|
||||
typeof obj.completion === "string" ||
|
||||
typeof obj.output === "string" ||
|
||||
obj.message !== undefined ||
|
||||
obj.messages !== undefined ||
|
||||
obj.content !== undefined,
|
||||
{ message: "Not a Claude JSON payload" },
|
||||
);
|
||||
|
||||
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
||||
|
||||
export function parseClaudeJson(
|
||||
raw: string,
|
||||
raw: string,
|
||||
): ClaudeJsonParseResult | undefined {
|
||||
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
||||
let firstParsed: unknown;
|
||||
const candidates = [
|
||||
raw,
|
||||
...raw
|
||||
.split(/\n+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (firstParsed === undefined) firstParsed = parsed;
|
||||
let validation: ClaudeSafeParse | { success: false };
|
||||
try {
|
||||
validation = ClaudeJsonSchema.safeParse(parsed);
|
||||
} catch {
|
||||
validation = { success: false } as const;
|
||||
}
|
||||
const validated = validation.success ? validation.data : parsed;
|
||||
const isLikelyClaude =
|
||||
typeof validated === "object" &&
|
||||
validated !== null &&
|
||||
("result" in validated ||
|
||||
"text" in validated ||
|
||||
"completion" in validated ||
|
||||
"output" in validated);
|
||||
const text = extractClaudeText(validated);
|
||||
if (text)
|
||||
return {
|
||||
parsed: validated,
|
||||
text,
|
||||
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
||||
valid: Boolean(validation?.success || isLikelyClaude),
|
||||
};
|
||||
} catch {
|
||||
// ignore parse errors; try next candidate
|
||||
}
|
||||
}
|
||||
if (firstParsed !== undefined) {
|
||||
let validation: ClaudeSafeParse | { success: false };
|
||||
try {
|
||||
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
||||
} catch {
|
||||
validation = { success: false } as const;
|
||||
}
|
||||
const validated = validation.success ? validation.data : firstParsed;
|
||||
const isLikelyClaude =
|
||||
typeof validated === "object" &&
|
||||
validated !== null &&
|
||||
("result" in validated ||
|
||||
"text" in validated ||
|
||||
"completion" in validated ||
|
||||
"output" in validated);
|
||||
return {
|
||||
parsed: validated,
|
||||
text: extractClaudeText(validated),
|
||||
valid: Boolean(validation?.success || isLikelyClaude),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
||||
let firstParsed: unknown;
|
||||
const candidates = [
|
||||
raw,
|
||||
...raw
|
||||
.split(/\n+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (firstParsed === undefined) firstParsed = parsed;
|
||||
let validation: ClaudeSafeParse | { success: false };
|
||||
try {
|
||||
validation = ClaudeJsonSchema.safeParse(parsed);
|
||||
} catch {
|
||||
validation = { success: false } as const;
|
||||
}
|
||||
const validated = validation.success ? validation.data : parsed;
|
||||
const isLikelyClaude =
|
||||
typeof validated === "object" &&
|
||||
validated !== null &&
|
||||
("result" in validated ||
|
||||
"text" in validated ||
|
||||
"completion" in validated ||
|
||||
"output" in validated);
|
||||
const text = extractClaudeText(validated);
|
||||
if (text)
|
||||
return {
|
||||
parsed: validated,
|
||||
text,
|
||||
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
||||
valid: Boolean(validation?.success || isLikelyClaude),
|
||||
};
|
||||
} catch {
|
||||
// ignore parse errors; try next candidate
|
||||
}
|
||||
}
|
||||
if (firstParsed !== undefined) {
|
||||
let validation: ClaudeSafeParse | { success: false };
|
||||
try {
|
||||
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
||||
} catch {
|
||||
validation = { success: false } as const;
|
||||
}
|
||||
const validated = validation.success ? validation.data : firstParsed;
|
||||
const isLikelyClaude =
|
||||
typeof validated === "object" &&
|
||||
validated !== null &&
|
||||
("result" in validated ||
|
||||
"text" in validated ||
|
||||
"completion" in validated ||
|
||||
"output" in validated);
|
||||
return {
|
||||
parsed: validated,
|
||||
text: extractClaudeText(validated),
|
||||
valid: Boolean(validation?.success || isLikelyClaude),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseClaudeJsonText(raw: string): string | undefined {
|
||||
const parsed = parseClaudeJson(raw);
|
||||
return parsed?.text;
|
||||
const parsed = parseClaudeJson(raw);
|
||||
return parsed?.text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runCommandReply, summarizeClaudeMetadata } from "./command-reply.js";
|
||||
import type { ReplyPayload } from "./types.js";
|
||||
|
||||
const noopTemplateCtx = {
|
||||
Body: "hello",
|
||||
BodyStripped: "hello",
|
||||
SessionId: "sess",
|
||||
IsNewSession: "true",
|
||||
};
|
||||
|
||||
type RunnerResult = {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
signal?: string | null;
|
||||
killed?: boolean;
|
||||
};
|
||||
|
||||
function makeRunner(result: RunnerResult, capture: ReplyPayload[] = []) {
|
||||
return vi.fn(async (argv: string[]) => {
|
||||
capture.push({ text: argv.join(" "), argv });
|
||||
return {
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? "",
|
||||
code: result.code ?? 0,
|
||||
signal: result.signal ?? null,
|
||||
killed: result.killed ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const enqueueImmediate = vi.fn(
|
||||
async <T>(
|
||||
task: () => Promise<T>,
|
||||
opts?: { onWait?: (ms: number, ahead: number) => void },
|
||||
) => {
|
||||
opts?.onWait?.(25, 2);
|
||||
return task();
|
||||
},
|
||||
);
|
||||
|
||||
describe("summarizeClaudeMetadata", () => {
|
||||
it("builds concise meta string", () => {
|
||||
const meta = summarizeClaudeMetadata({
|
||||
duration_ms: 1200,
|
||||
num_turns: 3,
|
||||
total_cost_usd: 0.012345,
|
||||
usage: { server_tool_use: { a: 1, b: 2 } },
|
||||
modelUsage: { "claude-3": 2, haiku: 1 },
|
||||
});
|
||||
expect(meta).toContain("duration=1200ms");
|
||||
expect(meta).toContain("turns=3");
|
||||
expect(meta).toContain("cost=$0.0123");
|
||||
expect(meta).toContain("tool_calls=3");
|
||||
expect(meta).toContain("models=claude-3,haiku");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCommandReply", () => {
|
||||
it("injects claude flags and identity prefix", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||
const { payload } = await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
|
||||
expect(payload?.text).toBe("ok");
|
||||
const finalArgv = captures[0].argv as string[];
|
||||
expect(finalArgv).toContain("--output-format");
|
||||
expect(finalArgv).toContain("json");
|
||||
expect(finalArgv).toContain("-p");
|
||||
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
|
||||
});
|
||||
|
||||
it("omits identity prefix on resumed session when sendSystemOnce=true", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||
await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
isNewSession: false,
|
||||
isFirstTurnInSession: false,
|
||||
systemSent: true,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
const finalArgv = captures[0].argv as string[];
|
||||
expect(finalArgv.at(-1)).not.toContain("You are Clawd (Claude)");
|
||||
});
|
||||
|
||||
it("prepends identity on first turn when sendSystemOnce=true", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||
await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
const finalArgv = captures[0].argv as string[];
|
||||
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
|
||||
});
|
||||
|
||||
it("still prepends identity if resume session but systemSent=false", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||
await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
isNewSession: false,
|
||||
isFirstTurnInSession: false,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
const finalArgv = captures[0].argv as string[];
|
||||
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
|
||||
});
|
||||
|
||||
it("picks session resume args when not new", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "hi" }, captures);
|
||||
await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["cli", "{{Body}}"],
|
||||
session: {
|
||||
sessionArgNew: ["--new", "{{SessionId}}"],
|
||||
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||||
},
|
||||
},
|
||||
templatingCtx: { ...noopTemplateCtx, SessionId: "abc" },
|
||||
sendSystemOnce: true,
|
||||
isNewSession: false,
|
||||
isFirstTurnInSession: false,
|
||||
systemSent: true,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
const argv = captures[0].argv as string[];
|
||||
expect(argv).toContain("--resume");
|
||||
expect(argv).toContain("abc");
|
||||
});
|
||||
|
||||
it("returns timeout text with partial snippet", async () => {
|
||||
const runner = vi.fn(async () => {
|
||||
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
|
||||
});
|
||||
const { payload, meta } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"] },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 10,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(payload?.text).toContain("Command timed out after 1s");
|
||||
expect(payload?.text).toContain("partial output");
|
||||
expect(meta.killed).toBe(true);
|
||||
});
|
||||
|
||||
it("includes cwd hint in timeout message", async () => {
|
||||
const runner = vi.fn(async () => {
|
||||
throw { stdout: "", killed: true, signal: "SIGKILL" };
|
||||
});
|
||||
const { payload } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"], cwd: "/tmp/work" },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 5,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(payload?.text).toContain("(cwd: /tmp/work)");
|
||||
});
|
||||
|
||||
it("parses MEDIA tokens and respects mediaMaxMb for local files", async () => {
|
||||
const tmp = path.join(os.tmpdir(), `warelay-test-${Date.now()}.bin`);
|
||||
const bigBuffer = Buffer.alloc(2 * 1024 * 1024, 1);
|
||||
await fs.writeFile(tmp, bigBuffer);
|
||||
const runner = makeRunner({
|
||||
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
|
||||
});
|
||||
const { payload } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"], mediaMaxMb: 1 },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(payload?.mediaUrls).toEqual(["https://example.com/img.jpg"]);
|
||||
await fs.unlink(tmp);
|
||||
});
|
||||
|
||||
it("emits Claude metadata", async () => {
|
||||
const runner = makeRunner({
|
||||
stdout:
|
||||
'{"text":"hi","duration_ms":50,"total_cost_usd":0.0001,"usage":{"server_tool_use":{"a":1}}}',
|
||||
});
|
||||
const { meta } = await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(meta.claudeMeta).toContain("duration=50ms");
|
||||
expect(meta.claudeMeta).toContain("tool_calls=1");
|
||||
});
|
||||
|
||||
it("captures queue wait metrics in meta", async () => {
|
||||
const runner = makeRunner({ stdout: "ok" });
|
||||
const { meta } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "{{Body}}"] },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 100,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(meta.queuedMs).toBe(25);
|
||||
expect(meta.queuedAhead).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { WarelayConfig } from "../config/config.js";
|
||||
import { isVerbose, logVerbose } from "../globals.js";
|
||||
import { logError } from "../logger.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { enqueueCommand } from "../process/command-queue.js";
|
||||
import type { runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
CLAUDE_BIN,
|
||||
CLAUDE_IDENTITY_PREFIX,
|
||||
type ClaudeJsonParseResult,
|
||||
parseClaudeJson,
|
||||
} from "./claude.js";
|
||||
import { applyTemplate, type TemplateContext } from "./templating.js";
|
||||
import type { ReplyPayload } from "./types.js";
|
||||
|
||||
type CommandReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"] & {
|
||||
mode: "command";
|
||||
};
|
||||
|
||||
type EnqueueRunner = typeof enqueueCommand;
|
||||
|
||||
type CommandReplyParams = {
|
||||
reply: CommandReplyConfig;
|
||||
templatingCtx: TemplateContext;
|
||||
sendSystemOnce: boolean;
|
||||
isNewSession: boolean;
|
||||
isFirstTurnInSession: boolean;
|
||||
systemSent: boolean;
|
||||
timeoutMs: number;
|
||||
timeoutSeconds: number;
|
||||
commandRunner: typeof runCommandWithTimeout;
|
||||
enqueue?: EnqueueRunner;
|
||||
};
|
||||
|
||||
export type CommandReplyMeta = {
|
||||
durationMs: number;
|
||||
queuedMs?: number;
|
||||
queuedAhead?: number;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
killed?: boolean;
|
||||
claudeMeta?: string;
|
||||
};
|
||||
|
||||
export type CommandReplyResult = {
|
||||
payload?: ReplyPayload;
|
||||
meta: CommandReplyMeta;
|
||||
};
|
||||
|
||||
export function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (typeof obj.duration_ms === "number") {
|
||||
parts.push(`duration=${obj.duration_ms}ms`);
|
||||
}
|
||||
if (typeof obj.duration_api_ms === "number") {
|
||||
parts.push(`api=${obj.duration_api_ms}ms`);
|
||||
}
|
||||
if (typeof obj.num_turns === "number") {
|
||||
parts.push(`turns=${obj.num_turns}`);
|
||||
}
|
||||
if (typeof obj.total_cost_usd === "number") {
|
||||
parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`);
|
||||
}
|
||||
|
||||
const usage = obj.usage;
|
||||
if (usage && typeof usage === "object") {
|
||||
const serverToolUse = (
|
||||
usage as { server_tool_use?: Record<string, unknown> }
|
||||
).server_tool_use;
|
||||
if (serverToolUse && typeof serverToolUse === "object") {
|
||||
const toolCalls = Object.values(serverToolUse).reduce<number>(
|
||||
(sum, val) => {
|
||||
if (typeof val === "number") return sum + val;
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
|
||||
}
|
||||
}
|
||||
|
||||
const modelUsage = obj.modelUsage;
|
||||
if (modelUsage && typeof modelUsage === "object") {
|
||||
const models = Object.keys(modelUsage as Record<string, unknown>);
|
||||
if (models.length) {
|
||||
const display =
|
||||
models.length > 2
|
||||
? `${models.slice(0, 2).join(",")}+${models.length - 2}`
|
||||
: models.join(",");
|
||||
parts.push(`models=${display}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(", ") : undefined;
|
||||
}
|
||||
|
||||
export async function runCommandReply(
|
||||
params: CommandReplyParams,
|
||||
): Promise<CommandReplyResult> {
|
||||
const {
|
||||
reply,
|
||||
templatingCtx,
|
||||
sendSystemOnce,
|
||||
isNewSession,
|
||||
isFirstTurnInSession,
|
||||
systemSent,
|
||||
timeoutMs,
|
||||
timeoutSeconds,
|
||||
commandRunner,
|
||||
enqueue = enqueueCommand,
|
||||
} = params;
|
||||
|
||||
if (!reply.command?.length) {
|
||||
throw new Error("reply.command is required for mode=command");
|
||||
}
|
||||
|
||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||
const templatePrefix =
|
||||
reply.template && (!sendSystemOnce || isFirstTurnInSession || !systemSent)
|
||||
? applyTemplate(reply.template, templatingCtx)
|
||||
: "";
|
||||
if (templatePrefix && argv.length > 0) {
|
||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
||||
}
|
||||
|
||||
// Ensure Claude commands can emit plain text by forcing --output-format when configured.
|
||||
if (
|
||||
reply.claudeOutputFormat &&
|
||||
argv.length > 0 &&
|
||||
path.basename(argv[0]) === CLAUDE_BIN
|
||||
) {
|
||||
const hasOutputFormat = argv.some(
|
||||
(part) =>
|
||||
part === "--output-format" || part.startsWith("--output-format="),
|
||||
);
|
||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
||||
if (!hasOutputFormat) {
|
||||
argv = [
|
||||
...argv.slice(0, insertBeforeBody),
|
||||
"--output-format",
|
||||
reply.claudeOutputFormat,
|
||||
...argv.slice(insertBeforeBody),
|
||||
];
|
||||
}
|
||||
const hasPrintFlag = argv.some(
|
||||
(part) => part === "-p" || part === "--print",
|
||||
);
|
||||
if (!hasPrintFlag) {
|
||||
const insertIdx = Math.max(argv.length - 1, 0);
|
||||
argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)];
|
||||
}
|
||||
}
|
||||
|
||||
// Inject session args if configured (use resume for existing, session-id for new)
|
||||
if (reply.session) {
|
||||
const sessionArgList = (
|
||||
isNewSession
|
||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
||||
).map((part) => applyTemplate(part, templatingCtx));
|
||||
if (sessionArgList.length) {
|
||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||
const insertAt =
|
||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||
argv = [
|
||||
...argv.slice(0, insertAt),
|
||||
...sessionArgList,
|
||||
...argv.slice(insertAt),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
let finalArgv = argv;
|
||||
const isClaudeInvocation =
|
||||
finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN;
|
||||
const shouldPrependIdentity =
|
||||
isClaudeInvocation && !(sendSystemOnce && systemSent);
|
||||
if (shouldPrependIdentity && finalArgv.length > 0) {
|
||||
const bodyIdx = finalArgv.length - 1;
|
||||
const existingBody = finalArgv[bodyIdx] ?? "";
|
||||
finalArgv = [
|
||||
...finalArgv.slice(0, bodyIdx),
|
||||
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
|
||||
];
|
||||
}
|
||||
logVerbose(
|
||||
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
||||
);
|
||||
|
||||
const started = Date.now();
|
||||
let queuedMs: number | undefined;
|
||||
let queuedAhead: number | undefined;
|
||||
try {
|
||||
const { stdout, stderr, code, signal, killed } = await enqueue(
|
||||
() => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }),
|
||||
{
|
||||
onWait: (waitMs, ahead) => {
|
||||
queuedMs = waitMs;
|
||||
queuedAhead = ahead;
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
const rawStdout = stdout.trim();
|
||||
let mediaFromCommand: string[] | undefined;
|
||||
let trimmed = rawStdout;
|
||||
if (stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||
}
|
||||
let parsed: ClaudeJsonParseResult | undefined;
|
||||
if (
|
||||
trimmed &&
|
||||
(reply.claudeOutputFormat === "json" || isClaudeInvocation)
|
||||
) {
|
||||
parsed = parseClaudeJson(trimmed);
|
||||
if (parsed?.parsed && isVerbose()) {
|
||||
const summary = summarizeClaudeMetadata(parsed.parsed);
|
||||
if (summary) logVerbose(`Claude JSON meta: ${summary}`);
|
||||
logVerbose(
|
||||
`Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`,
|
||||
);
|
||||
}
|
||||
if (parsed?.text) {
|
||||
logVerbose(
|
||||
`Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`,
|
||||
);
|
||||
trimmed = parsed.text.trim();
|
||||
} else {
|
||||
logVerbose("Claude JSON parse failed; returning raw stdout");
|
||||
}
|
||||
}
|
||||
const { text: cleanedText, mediaUrls: mediaFound } =
|
||||
splitMediaFromOutput(trimmed);
|
||||
trimmed = cleanedText;
|
||||
if (mediaFound?.length) {
|
||||
mediaFromCommand = mediaFound;
|
||||
if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`);
|
||||
} else if (isVerbose()) {
|
||||
logVerbose("No MEDIA token extracted from final text");
|
||||
}
|
||||
if (!trimmed && !mediaFromCommand) {
|
||||
const meta = parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined;
|
||||
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
|
||||
logVerbose("No text/media produced; injecting fallback notice to user");
|
||||
}
|
||||
logVerbose(`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`);
|
||||
logVerbose(`Command auto-reply finished in ${Date.now() - started}ms`);
|
||||
if ((code ?? 0) !== 0) {
|
||||
console.error(
|
||||
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
||||
);
|
||||
return {
|
||||
payload: undefined,
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed
|
||||
? summarizeClaudeMetadata(parsed.parsed)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (killed && !signal) {
|
||||
console.error(
|
||||
`Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`,
|
||||
);
|
||||
return {
|
||||
payload: undefined,
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed
|
||||
? summarizeClaudeMetadata(parsed.parsed)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
let mediaUrls =
|
||||
mediaFromCommand ?? (reply.mediaUrl ? [reply.mediaUrl] : undefined);
|
||||
|
||||
// If mediaMaxMb is set, skip local media paths larger than the cap.
|
||||
if (mediaUrls?.length && reply.mediaMaxMb) {
|
||||
const maxBytes = reply.mediaMaxMb * 1024 * 1024;
|
||||
const filtered: string[] = [];
|
||||
for (const url of mediaUrls) {
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
filtered.push(url);
|
||||
continue;
|
||||
}
|
||||
const abs = path.isAbsolute(url) ? url : path.resolve(url);
|
||||
try {
|
||||
const stats = await fs.stat(abs);
|
||||
if (stats.size <= maxBytes) {
|
||||
filtered.push(url);
|
||||
} else if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Skipping media ${url} (${(stats.size / (1024 * 1024)).toFixed(2)}MB) over cap ${reply.mediaMaxMb}MB`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
filtered.push(url);
|
||||
}
|
||||
}
|
||||
mediaUrls = filtered;
|
||||
}
|
||||
|
||||
const payload =
|
||||
trimmed || mediaUrls?.length
|
||||
? {
|
||||
text: trimmed || undefined,
|
||||
mediaUrl: mediaUrls?.[0],
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined;
|
||||
const meta: CommandReplyMeta = {
|
||||
durationMs: Date.now() - started,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
|
||||
};
|
||||
if (isVerbose()) {
|
||||
logVerbose(`Command auto-reply meta: ${JSON.stringify(meta)}`);
|
||||
}
|
||||
return { payload, meta };
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - started;
|
||||
const anyErr = err as { killed?: boolean; signal?: string };
|
||||
const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL";
|
||||
const errorObj = err as { stdout?: string; stderr?: string };
|
||||
if (errorObj.stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${errorObj.stderr.trim()}`);
|
||||
}
|
||||
if (timeoutHit) {
|
||||
console.error(
|
||||
`Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`,
|
||||
);
|
||||
const baseMsg =
|
||||
"Command timed out after " +
|
||||
`${timeoutSeconds}s${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}. Try a shorter prompt or split the request.`;
|
||||
const partial = errorObj.stdout?.trim();
|
||||
const partialSnippet =
|
||||
partial && partial.length > 800
|
||||
? `${partial.slice(0, 800)}...`
|
||||
: partial;
|
||||
const text = partialSnippet
|
||||
? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}`
|
||||
: baseMsg;
|
||||
return {
|
||||
payload: { text },
|
||||
meta: {
|
||||
durationMs: elapsed,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: undefined,
|
||||
signal: anyErr.signal,
|
||||
killed: anyErr.killed,
|
||||
},
|
||||
};
|
||||
}
|
||||
logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`);
|
||||
return {
|
||||
payload: undefined,
|
||||
meta: {
|
||||
durationMs: elapsed,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: undefined,
|
||||
signal: anyErr.signal,
|
||||
killed: anyErr.killed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+371
-461
@@ -1,505 +1,415 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
DEFAULT_RESET_TRIGGER,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
DEFAULT_RESET_TRIGGER,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { info, isVerbose, logVerbose } from "../globals.js";
|
||||
import { logError } from "../logger.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { enqueueCommand } from "../process/command-queue.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import type { TwilioRequester } from "../twilio/types.js";
|
||||
import { sendTypingIndicator } from "../twilio/typing.js";
|
||||
import { runCommandReply } from "./command-reply.js";
|
||||
import {
|
||||
CLAUDE_BIN,
|
||||
CLAUDE_IDENTITY_PREFIX,
|
||||
type ClaudeJsonParseResult,
|
||||
parseClaudeJson,
|
||||
} from "./claude.js";
|
||||
import {
|
||||
applyTemplate,
|
||||
type MsgContext,
|
||||
type TemplateContext,
|
||||
applyTemplate,
|
||||
type MsgContext,
|
||||
type TemplateContext,
|
||||
} from "./templating.js";
|
||||
import { isAudio, transcribeInboundAudio } from "./transcription.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||
|
||||
type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (typeof obj.duration_ms === "number") {
|
||||
parts.push(`duration=${obj.duration_ms}ms`);
|
||||
}
|
||||
if (typeof obj.duration_api_ms === "number") {
|
||||
parts.push(`api=${obj.duration_api_ms}ms`);
|
||||
}
|
||||
if (typeof obj.num_turns === "number") {
|
||||
parts.push(`turns=${obj.num_turns}`);
|
||||
}
|
||||
if (typeof obj.total_cost_usd === "number") {
|
||||
parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`);
|
||||
}
|
||||
|
||||
const usage = obj.usage;
|
||||
if (usage && typeof usage === "object") {
|
||||
const serverToolUse = (
|
||||
usage as { server_tool_use?: Record<string, unknown> }
|
||||
).server_tool_use;
|
||||
if (serverToolUse && typeof serverToolUse === "object") {
|
||||
const toolCalls = Object.values(serverToolUse).reduce<number>(
|
||||
(sum, val) => {
|
||||
if (typeof val === "number") return sum + val;
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
|
||||
}
|
||||
}
|
||||
|
||||
const modelUsage = obj.modelUsage;
|
||||
if (modelUsage && typeof modelUsage === "object") {
|
||||
const models = Object.keys(modelUsage as Record<string, unknown>);
|
||||
if (models.length) {
|
||||
const display =
|
||||
models.length > 2
|
||||
? `${models.slice(0, 2).join(",")}+${models.length - 2}`
|
||||
: models.join(",");
|
||||
parts.push(`models=${display}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(", ") : undefined;
|
||||
}
|
||||
|
||||
export type ReplyPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
};
|
||||
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||
|
||||
export async function getReplyFromConfig(
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: WarelayConfig,
|
||||
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: WarelayConfig,
|
||||
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
||||
): Promise<ReplyPayload | undefined> {
|
||||
// Choose reply from config: static text or external command stdout.
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const reply = cfg.inbound?.reply;
|
||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let started = false;
|
||||
const onReplyStart = async () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
await opts?.onReplyStart?.();
|
||||
};
|
||||
// Choose reply from config: static text or external command stdout.
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const reply = cfg.inbound?.reply;
|
||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let started = false;
|
||||
const triggerTyping = async () => {
|
||||
await opts?.onReplyStart?.();
|
||||
};
|
||||
const onReplyStart = async () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
await triggerTyping();
|
||||
};
|
||||
let typingTimer: NodeJS.Timeout | undefined;
|
||||
const typingIntervalMs =
|
||||
reply?.mode === "command"
|
||||
? (reply.typingIntervalSeconds ??
|
||||
reply?.session?.typingIntervalSeconds ??
|
||||
8) * 1000
|
||||
: 0;
|
||||
const cleanupTyping = () => {
|
||||
if (typingTimer) {
|
||||
clearInterval(typingTimer);
|
||||
typingTimer = undefined;
|
||||
}
|
||||
};
|
||||
const startTypingLoop = async () => {
|
||||
if (!opts?.onReplyStart) return;
|
||||
if (typingIntervalMs <= 0) return;
|
||||
if (typingTimer) return;
|
||||
await triggerTyping();
|
||||
typingTimer = setInterval(() => {
|
||||
void triggerTyping();
|
||||
}, typingIntervalMs);
|
||||
};
|
||||
let transcribedText: string | undefined;
|
||||
|
||||
// Optional session handling (conversation reuse + /new resets)
|
||||
const sessionCfg = reply?.session;
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: [DEFAULT_RESET_TRIGGER];
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
// Optional audio transcription before templating/session handling.
|
||||
if (cfg.inbound?.transcribeAudio && isAudio(ctx.MediaType)) {
|
||||
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
|
||||
if (transcribed?.text) {
|
||||
transcribedText = transcribed.text;
|
||||
ctx.Body = transcribed.text;
|
||||
ctx.Transcript = transcribed.text;
|
||||
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||
}
|
||||
}
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let isNewSession = false;
|
||||
let bodyStripped: string | undefined;
|
||||
// Optional session handling (conversation reuse + /new resets)
|
||||
const sessionCfg = reply?.session;
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: [DEFAULT_RESET_TRIGGER];
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
let sessionStore: ReturnType<typeof loadSessionStore> | undefined;
|
||||
let sessionKey: string | undefined;
|
||||
|
||||
if (sessionCfg) {
|
||||
const trimmedBody = (ctx.Body ?? "").trim();
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
if (trimmedBody === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
break;
|
||||
}
|
||||
const triggerPrefix = `${trigger} `;
|
||||
if (trimmedBody.startsWith(triggerPrefix)) {
|
||||
isNewSession = true;
|
||||
bodyStripped = trimmedBody.slice(trigger.length).trimStart();
|
||||
break;
|
||||
}
|
||||
}
|
||||
let sessionId: string | undefined;
|
||||
let isNewSession = false;
|
||||
let bodyStripped: string | undefined;
|
||||
let systemSent = false;
|
||||
|
||||
const sessionKey = deriveSessionKey(sessionScope, ctx);
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
if (sessionCfg) {
|
||||
const trimmedBody = (ctx.Body ?? "").trim();
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
if (trimmedBody === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
break;
|
||||
}
|
||||
const triggerPrefix = `${trigger} `;
|
||||
if (trimmedBody.startsWith(triggerPrefix)) {
|
||||
isNewSession = true;
|
||||
bodyStripped = trimmedBody.slice(trigger.length).trimStart();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNewSession && freshEntry) {
|
||||
sessionId = entry.sessionId;
|
||||
} else {
|
||||
sessionId = crypto.randomUUID();
|
||||
isNewSession = true;
|
||||
}
|
||||
sessionKey = deriveSessionKey(sessionScope, ctx);
|
||||
sessionStore = loadSessionStore(storePath);
|
||||
const entry = sessionStore[sessionKey];
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
|
||||
store[sessionKey] = { sessionId, updatedAt: Date.now() };
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
if (!isNewSession && freshEntry) {
|
||||
sessionId = entry.sessionId;
|
||||
systemSent = entry.systemSent ?? false;
|
||||
} else {
|
||||
sessionId = crypto.randomUUID();
|
||||
isNewSession = true;
|
||||
systemSent = false;
|
||||
}
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
BodyStripped: bodyStripped ?? ctx.Body,
|
||||
SessionId: sessionId,
|
||||
IsNewSession: isNewSession ? "true" : "false",
|
||||
};
|
||||
sessionStore[sessionKey] = { sessionId, updatedAt: Date.now(), systemSent };
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
|
||||
// Optional prefix injected before Body for templating/command prompts.
|
||||
const bodyPrefix = reply?.bodyPrefix
|
||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
||||
: "";
|
||||
const prefixedBody = bodyPrefix
|
||||
? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}`
|
||||
: (sessionCtx.BodyStripped ?? sessionCtx.Body);
|
||||
const mediaNote = ctx.MediaPath?.length
|
||||
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
|
||||
: undefined;
|
||||
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
|
||||
const mediaReplyHint =
|
||||
mediaNote && reply?.mode === "command"
|
||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||
: undefined;
|
||||
const commandBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
: prefixedBody;
|
||||
const templatingCtx: TemplateContext = {
|
||||
...sessionCtx,
|
||||
Body: commandBody,
|
||||
BodyStripped: commandBody,
|
||||
};
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
BodyStripped: bodyStripped ?? ctx.Body,
|
||||
SessionId: sessionId,
|
||||
IsNewSession: isNewSession ? "true" : "false",
|
||||
};
|
||||
|
||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||
const allowFrom = cfg.inbound?.allowFrom;
|
||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
if (!allowFrom.includes(from)) {
|
||||
logVerbose(
|
||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (!reply) {
|
||||
logVerbose("No inbound.reply configured; skipping auto-reply");
|
||||
return undefined;
|
||||
}
|
||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||
const allowFrom = cfg.inbound?.allowFrom;
|
||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
// Support "*" as wildcard to allow all senders
|
||||
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
||||
logVerbose(
|
||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
||||
);
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (reply.mode === "text" && reply.text) {
|
||||
await onReplyStart();
|
||||
logVerbose("Using text auto-reply from config");
|
||||
return {
|
||||
text: applyTemplate(reply.text, templatingCtx),
|
||||
mediaUrl: reply.mediaUrl,
|
||||
};
|
||||
}
|
||||
await startTypingLoop();
|
||||
|
||||
if (reply.mode === "command" && reply.command?.length) {
|
||||
await onReplyStart();
|
||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||
const templatePrefix = reply.template
|
||||
? applyTemplate(reply.template, templatingCtx)
|
||||
: "";
|
||||
if (templatePrefix && argv.length > 0) {
|
||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
||||
}
|
||||
// Optional prefix injected before Body for templating/command prompts.
|
||||
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
|
||||
const isFirstTurnInSession = isNewSession || !systemSent;
|
||||
const sessionIntro =
|
||||
isFirstTurnInSession && sessionCfg?.sessionIntro
|
||||
? applyTemplate(sessionCfg.sessionIntro, sessionCtx)
|
||||
: "";
|
||||
const bodyPrefix = reply?.bodyPrefix
|
||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
||||
: "";
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const prefixedBodyBase = (() => {
|
||||
let body = baseBody;
|
||||
if (!sendSystemOnce || isFirstTurnInSession) {
|
||||
body = bodyPrefix ? `${bodyPrefix}${body}` : body;
|
||||
}
|
||||
if (sessionIntro) {
|
||||
body = `${sessionIntro}\n\n${body}`;
|
||||
}
|
||||
return body;
|
||||
})();
|
||||
if (
|
||||
sessionCfg &&
|
||||
sendSystemOnce &&
|
||||
isFirstTurnInSession &&
|
||||
sessionStore &&
|
||||
sessionKey
|
||||
) {
|
||||
sessionStore[sessionKey] = {
|
||||
...(sessionStore[sessionKey] ?? {}),
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
};
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
systemSent = true;
|
||||
}
|
||||
|
||||
// Ensure Claude commands can emit plain text by forcing --output-format when configured.
|
||||
// We inject the flags only when the user points at the `claude` binary and has opted in via config,
|
||||
// so existing custom argv or non-Claude commands remain untouched.
|
||||
if (
|
||||
reply.claudeOutputFormat &&
|
||||
argv.length > 0 &&
|
||||
path.basename(argv[0]) === CLAUDE_BIN
|
||||
) {
|
||||
const hasOutputFormat = argv.some(
|
||||
(part) =>
|
||||
part === "--output-format" || part.startsWith("--output-format="),
|
||||
);
|
||||
// Keep the final argument as the prompt/body; insert options just before it.
|
||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
||||
if (!hasOutputFormat) {
|
||||
argv = [
|
||||
...argv.slice(0, insertBeforeBody),
|
||||
"--output-format",
|
||||
reply.claudeOutputFormat,
|
||||
...argv.slice(insertBeforeBody),
|
||||
];
|
||||
}
|
||||
const hasPrintFlag = argv.some(
|
||||
(part) => part === "-p" || part === "--print",
|
||||
);
|
||||
if (!hasPrintFlag) {
|
||||
const insertIdx = Math.max(argv.length - 1, 0);
|
||||
argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)];
|
||||
}
|
||||
}
|
||||
const prefixedBody =
|
||||
transcribedText && reply?.mode === "command"
|
||||
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: prefixedBodyBase;
|
||||
const mediaNote = ctx.MediaPath?.length
|
||||
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
|
||||
: undefined;
|
||||
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
|
||||
const mediaReplyHint =
|
||||
mediaNote && reply?.mode === "command"
|
||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||
: undefined;
|
||||
const commandBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
: prefixedBody;
|
||||
const templatingCtx: TemplateContext = {
|
||||
...sessionCtx,
|
||||
Body: commandBody,
|
||||
BodyStripped: commandBody,
|
||||
};
|
||||
if (!reply) {
|
||||
logVerbose("No inbound.reply configured; skipping auto-reply");
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Inject session args if configured (use resume for existing, session-id for new)
|
||||
if (reply.session) {
|
||||
const sessionArgList = (
|
||||
isNewSession
|
||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
||||
).map((part) => applyTemplate(part, templatingCtx));
|
||||
if (sessionArgList.length) {
|
||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||
const insertAt =
|
||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||
argv = [
|
||||
...argv.slice(0, insertAt),
|
||||
...sessionArgList,
|
||||
...argv.slice(insertAt),
|
||||
];
|
||||
}
|
||||
}
|
||||
let finalArgv = argv;
|
||||
const isClaudeInvocation =
|
||||
finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN;
|
||||
if (isClaudeInvocation && finalArgv.length > 0) {
|
||||
const bodyIdx = finalArgv.length - 1;
|
||||
const existingBody = finalArgv[bodyIdx] ?? "";
|
||||
finalArgv = [
|
||||
...finalArgv.slice(0, bodyIdx),
|
||||
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
|
||||
];
|
||||
}
|
||||
logVerbose(
|
||||
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
||||
);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const { stdout, stderr, code, signal, killed } = await enqueueCommand(
|
||||
() => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }),
|
||||
{
|
||||
onWait: (waitMs, queuedAhead) => {
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
const rawStdout = stdout.trim();
|
||||
let mediaFromCommand: string | undefined;
|
||||
let trimmed = rawStdout;
|
||||
if (stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||
}
|
||||
let parsed: ClaudeJsonParseResult | undefined;
|
||||
if (
|
||||
trimmed &&
|
||||
(reply.claudeOutputFormat === "json" || isClaudeInvocation)
|
||||
) {
|
||||
// Claude JSON mode: extract the human text for both logging and reply while keeping metadata.
|
||||
parsed = parseClaudeJson(trimmed);
|
||||
if (parsed?.parsed && isVerbose()) {
|
||||
const summary = summarizeClaudeMetadata(parsed.parsed);
|
||||
if (summary) logVerbose(`Claude JSON meta: ${summary}`);
|
||||
logVerbose(
|
||||
`Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`,
|
||||
);
|
||||
}
|
||||
if (parsed?.text) {
|
||||
logVerbose(
|
||||
`Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`,
|
||||
);
|
||||
trimmed = parsed.text.trim();
|
||||
} else {
|
||||
logVerbose("Claude JSON parse failed; returning raw stdout");
|
||||
}
|
||||
}
|
||||
// Run media extraction once on the final human text (post-JSON parse if available).
|
||||
const { text: cleanedText, mediaUrl: mediaFound } =
|
||||
splitMediaFromOutput(trimmed);
|
||||
trimmed = cleanedText;
|
||||
if (mediaFound) {
|
||||
mediaFromCommand = mediaFound;
|
||||
if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`);
|
||||
} else if (isVerbose()) {
|
||||
logVerbose("No MEDIA token extracted from final text");
|
||||
}
|
||||
if (!trimmed && !mediaFromCommand) {
|
||||
const meta = parsed
|
||||
? summarizeClaudeMetadata(parsed.parsed)
|
||||
: undefined;
|
||||
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
|
||||
logVerbose("No text/media produced; injecting fallback notice to user");
|
||||
}
|
||||
logVerbose(
|
||||
`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`,
|
||||
);
|
||||
logVerbose(`Command auto-reply finished in ${Date.now() - started}ms`);
|
||||
if ((code ?? 0) !== 0) {
|
||||
console.error(
|
||||
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (killed && !signal) {
|
||||
console.error(
|
||||
`Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const mediaUrl = mediaFromCommand ?? reply.mediaUrl;
|
||||
return trimmed || mediaUrl
|
||||
? { text: trimmed || undefined, mediaUrl }
|
||||
: undefined;
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - started;
|
||||
const anyErr = err as { killed?: boolean; signal?: string };
|
||||
const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL";
|
||||
const errorObj = err as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
if (errorObj.stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${errorObj.stderr.trim()}`);
|
||||
}
|
||||
if (timeoutHit) {
|
||||
console.error(
|
||||
`Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`,
|
||||
);
|
||||
const baseMsg = `Command timed out after ${timeoutSeconds}s. Try a shorter prompt or split the request.`;
|
||||
const partial = errorObj.stdout?.trim();
|
||||
const partialSnippet =
|
||||
partial && partial.length > 800 ? `${partial.slice(0, 800)}...` : partial;
|
||||
const text = partialSnippet
|
||||
? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}`
|
||||
: baseMsg;
|
||||
return { text };
|
||||
} else {
|
||||
logError(
|
||||
`Command auto-reply failed after ${elapsed}ms: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (reply.mode === "text" && reply.text) {
|
||||
await onReplyStart();
|
||||
logVerbose("Using text auto-reply from config");
|
||||
const result = {
|
||||
text: applyTemplate(reply.text, templatingCtx),
|
||||
mediaUrl: reply.mediaUrl,
|
||||
};
|
||||
cleanupTyping();
|
||||
return result;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
if (reply && reply.mode === "command" && reply.command?.length) {
|
||||
await onReplyStart();
|
||||
const commandReply = {
|
||||
...reply,
|
||||
command: reply.command,
|
||||
mode: "command" as const,
|
||||
};
|
||||
try {
|
||||
const { payload, meta } = await runCommandReply({
|
||||
reply: commandReply,
|
||||
templatingCtx,
|
||||
sendSystemOnce,
|
||||
isNewSession,
|
||||
isFirstTurnInSession,
|
||||
systemSent,
|
||||
timeoutMs,
|
||||
timeoutSeconds,
|
||||
commandRunner,
|
||||
});
|
||||
if (meta.claudeMeta && isVerbose()) {
|
||||
logVerbose(`Claude JSON meta: ${meta.claudeMeta}`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
cleanupTyping();
|
||||
}
|
||||
}
|
||||
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type TwilioLikeClient = TwilioRequester & {
|
||||
messages: {
|
||||
create: (opts: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
body: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
messages: {
|
||||
create: (opts: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
body: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function autoReplyIfConfigured(
|
||||
client: TwilioLikeClient,
|
||||
message: MessageInstance,
|
||||
configOverride?: WarelayConfig,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
client: TwilioLikeClient,
|
||||
message: MessageInstance,
|
||||
configOverride?: WarelayConfig,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<void> {
|
||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||
const ctx: MsgContext = {
|
||||
Body: message.body ?? undefined,
|
||||
From: message.from ?? undefined,
|
||||
To: message.to ?? undefined,
|
||||
MessageSid: message.sid,
|
||||
};
|
||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||
const ctx: MsgContext = {
|
||||
Body: message.body ?? undefined,
|
||||
From: message.from ?? undefined,
|
||||
To: message.to ?? undefined,
|
||||
MessageSid: message.sid,
|
||||
};
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
// Attach media hints for transcription/templates if present on Twilio payloads.
|
||||
const mediaUrl = (message as { mediaUrl?: string }).mediaUrl;
|
||||
if (mediaUrl) ctx.MediaUrl = mediaUrl;
|
||||
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctx,
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
||||
},
|
||||
configOverride,
|
||||
);
|
||||
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) return;
|
||||
// Optional audio transcription before building reply.
|
||||
const mediaField = (message as { media?: unknown }).media;
|
||||
const mediaItems = Array.isArray(mediaField) ? mediaField : [];
|
||||
if (cfg.inbound?.transcribeAudio && mediaItems.length) {
|
||||
const media = mediaItems[0];
|
||||
const contentType = (media as { contentType?: string }).contentType;
|
||||
if (contentType?.startsWith("audio")) {
|
||||
const transcribed = await transcribeInboundAudio(cfg, ctx, runtime);
|
||||
if (transcribed?.text) {
|
||||
ctx.Body = transcribed.text;
|
||||
ctx.MediaType = contentType;
|
||||
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const replyFrom = message.to;
|
||||
const replyTo = message.from;
|
||||
if (!replyFrom || !replyTo) {
|
||||
if (isVerbose())
|
||||
console.error(
|
||||
"Skipping auto-reply: missing to/from on inbound message",
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctx,
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
if (
|
||||
!replyResult ||
|
||||
(!replyResult.text &&
|
||||
!replyResult.mediaUrl &&
|
||||
!replyResult.mediaUrls?.length)
|
||||
)
|
||||
return;
|
||||
|
||||
if (replyResult.text) {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
||||
);
|
||||
} else {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
||||
);
|
||||
}
|
||||
const replyFrom = message.to;
|
||||
const replyTo = message.from;
|
||||
if (!replyFrom || !replyTo) {
|
||||
if (isVerbose())
|
||||
console.error(
|
||||
"Skipping auto-reply: missing to/from on inbound message",
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: replyFrom,
|
||||
to: replyTo,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
info(
|
||||
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const anyErr = err as {
|
||||
code?: string | number;
|
||||
message?: unknown;
|
||||
moreInfo?: unknown;
|
||||
status?: string | number;
|
||||
response?: { body?: unknown };
|
||||
};
|
||||
const { code, status } = anyErr;
|
||||
const msg =
|
||||
typeof anyErr?.message === "string"
|
||||
? anyErr.message
|
||||
: (anyErr?.message ?? err);
|
||||
runtime.error(
|
||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
||||
);
|
||||
if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`);
|
||||
const responseBody = anyErr?.response?.body;
|
||||
if (responseBody) {
|
||||
runtime.error("Response body:");
|
||||
runtime.error(JSON.stringify(responseBody, null, 2));
|
||||
}
|
||||
}
|
||||
if (replyResult.text) {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
||||
);
|
||||
} else {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const mediaList = replyResult.mediaUrls?.length
|
||||
? replyResult.mediaUrls
|
||||
: replyResult.mediaUrl
|
||||
? [replyResult.mediaUrl]
|
||||
: [];
|
||||
|
||||
const sendTwilio = async (body: string, media?: string) => {
|
||||
let resolvedMedia = media;
|
||||
if (resolvedMedia && !/^https?:\/\//i.test(resolvedMedia)) {
|
||||
const hosted = await ensureMediaHosted(resolvedMedia);
|
||||
resolvedMedia = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: replyFrom,
|
||||
to: replyTo,
|
||||
body,
|
||||
...(resolvedMedia ? { mediaUrl: [resolvedMedia] } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
await sendTwilio(replyResult.text ?? "");
|
||||
} else {
|
||||
// First media with body (if any), then remaining as separate media-only sends.
|
||||
await sendTwilio(replyResult.text ?? "", mediaList[0]);
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
await sendTwilio("", extra);
|
||||
}
|
||||
}
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
info(
|
||||
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const anyErr = err as {
|
||||
code?: string | number;
|
||||
message?: unknown;
|
||||
moreInfo?: unknown;
|
||||
status?: string | number;
|
||||
response?: { body?: unknown };
|
||||
};
|
||||
const { code, status } = anyErr;
|
||||
const msg =
|
||||
typeof anyErr?.message === "string"
|
||||
? anyErr.message
|
||||
: (anyErr?.message ?? err);
|
||||
runtime.error(
|
||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
||||
);
|
||||
if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`);
|
||||
const responseBody = anyErr?.response?.body;
|
||||
if (responseBody) {
|
||||
runtime.error("Response body:");
|
||||
runtime.error(JSON.stringify(responseBody, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
export type MsgContext = {
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
MessageSid?: string;
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
MessageSid?: string;
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
Transcript?: string;
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
BodyStripped?: string;
|
||||
SessionId?: string;
|
||||
IsNewSession?: string;
|
||||
BodyStripped?: string;
|
||||
SessionId?: string;
|
||||
IsNewSession?: string;
|
||||
};
|
||||
|
||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||
export function applyTemplate(str: string, ctx: TemplateContext) {
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
const value = (ctx as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
const value = (ctx as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { transcribeInboundAudio } from "./transcription.js";
|
||||
|
||||
vi.mock("../globals.js", () => ({
|
||||
isVerbose: () => false,
|
||||
logVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runExec: vi.fn(async () => ({ stdout: "transcribed text\n" })),
|
||||
}));
|
||||
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
describe("transcribeInboundAudio", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("downloads mediaUrl to temp file and returns transcript", async () => {
|
||||
const tmpBuf = Buffer.from("audio-bytes");
|
||||
const tmpFile = path.join(os.tmpdir(), `warelay-audio-${Date.now()}.ogg`);
|
||||
await fs.writeFile(tmpFile, tmpBuf);
|
||||
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => tmpBuf,
|
||||
})) as unknown as typeof fetch;
|
||||
// @ts-expect-error override global fetch for test
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const cfg = {
|
||||
inbound: {
|
||||
transcribeAudio: {
|
||||
command: ["echo", "{{MediaPath}}"],
|
||||
timeoutSeconds: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx = { MediaUrl: "https://example.com/audio.ogg" };
|
||||
|
||||
const result = await transcribeInboundAudio(
|
||||
cfg as never,
|
||||
ctx as never,
|
||||
runtime as never,
|
||||
);
|
||||
expect(result?.text).toBe("transcribed text");
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when no transcription command", async () => {
|
||||
const res = await transcribeInboundAudio(
|
||||
{ inbound: {} } as never,
|
||||
{} as never,
|
||||
runtime as never,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { WarelayConfig } from "../config/config.js";
|
||||
import { isVerbose, logVerbose } from "../globals.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { applyTemplate, type MsgContext } from "./templating.js";
|
||||
|
||||
export function isAudio(mediaType?: string | null) {
|
||||
return Boolean(mediaType?.startsWith("audio"));
|
||||
}
|
||||
|
||||
export async function transcribeInboundAudio(
|
||||
cfg: WarelayConfig,
|
||||
ctx: MsgContext,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<{ text: string } | undefined> {
|
||||
const transcriber = cfg.inbound?.transcribeAudio;
|
||||
if (!transcriber?.command?.length) return undefined;
|
||||
|
||||
const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000);
|
||||
let tmpPath: string | undefined;
|
||||
let mediaPath = ctx.MediaPath;
|
||||
try {
|
||||
if (!mediaPath && ctx.MediaUrl) {
|
||||
const res = await fetch(ctx.MediaUrl);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const arrayBuf = await res.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuf);
|
||||
tmpPath = path.join(
|
||||
os.tmpdir(),
|
||||
`warelay-audio-${crypto.randomUUID()}.ogg`,
|
||||
);
|
||||
await fs.writeFile(tmpPath, buffer);
|
||||
mediaPath = tmpPath;
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Downloaded audio for transcription (${(buffer.length / (1024 * 1024)).toFixed(2)}MB) -> ${tmpPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!mediaPath) return undefined;
|
||||
|
||||
const templCtx: MsgContext = { ...ctx, MediaPath: mediaPath };
|
||||
const argv = transcriber.command.map((part) =>
|
||||
applyTemplate(part, templCtx),
|
||||
);
|
||||
if (isVerbose()) {
|
||||
logVerbose(`Transcribing audio via command: ${argv.join(" ")}`);
|
||||
}
|
||||
const { stdout } = await runExec(argv[0], argv.slice(1), {
|
||||
timeoutMs,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
});
|
||||
const text = stdout.trim();
|
||||
if (!text) return undefined;
|
||||
return { text };
|
||||
} catch (err) {
|
||||
runtime.error?.(`Audio transcription failed: ${String(err)}`);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (tmpPath) {
|
||||
void fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ReplyPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
+76
-76
@@ -6,9 +6,9 @@ import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
|
||||
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import {
|
||||
logWebSelfId,
|
||||
monitorWebProvider,
|
||||
sendMessageWeb,
|
||||
logWebSelfId,
|
||||
monitorWebProvider,
|
||||
sendMessageWeb,
|
||||
} from "../providers/web/index.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { createClient } from "../twilio/client.js";
|
||||
@@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js";
|
||||
import { waitForever } from "./wait.js";
|
||||
|
||||
export type CliDeps = {
|
||||
sendMessage: typeof sendMessage;
|
||||
sendMessageWeb: typeof sendMessageWeb;
|
||||
waitForFinalStatus: typeof waitForFinalStatus;
|
||||
assertProvider: typeof assertProvider;
|
||||
createClient?: typeof createClient;
|
||||
monitorTwilio: typeof monitorTwilio;
|
||||
listRecentMessages: typeof listRecentMessages;
|
||||
ensurePortAvailable: typeof ensurePortAvailable;
|
||||
startWebhook: typeof startWebhook;
|
||||
waitForever: typeof waitForever;
|
||||
ensureBinary: typeof ensureBinary;
|
||||
ensureFunnel: typeof ensureFunnel;
|
||||
getTailnetHostname: typeof getTailnetHostname;
|
||||
readEnv: typeof readEnv;
|
||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||
updateWebhook: typeof updateWebhook;
|
||||
handlePortError: typeof handlePortError;
|
||||
monitorWebProvider: typeof monitorWebProvider;
|
||||
resolveTwilioMediaUrl: (
|
||||
source: string,
|
||||
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
||||
) => Promise<string>;
|
||||
sendMessage: typeof sendMessage;
|
||||
sendMessageWeb: typeof sendMessageWeb;
|
||||
waitForFinalStatus: typeof waitForFinalStatus;
|
||||
assertProvider: typeof assertProvider;
|
||||
createClient?: typeof createClient;
|
||||
monitorTwilio: typeof monitorTwilio;
|
||||
listRecentMessages: typeof listRecentMessages;
|
||||
ensurePortAvailable: typeof ensurePortAvailable;
|
||||
startWebhook: typeof startWebhook;
|
||||
waitForever: typeof waitForever;
|
||||
ensureBinary: typeof ensureBinary;
|
||||
ensureFunnel: typeof ensureFunnel;
|
||||
getTailnetHostname: typeof getTailnetHostname;
|
||||
readEnv: typeof readEnv;
|
||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||
updateWebhook: typeof updateWebhook;
|
||||
handlePortError: typeof handlePortError;
|
||||
monitorWebProvider: typeof monitorWebProvider;
|
||||
resolveTwilioMediaUrl: (
|
||||
source: string,
|
||||
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
||||
) => Promise<string>;
|
||||
};
|
||||
|
||||
export async function monitorTwilio(
|
||||
intervalSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
maxIterations = Infinity,
|
||||
intervalSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
maxIterations = Infinity,
|
||||
) {
|
||||
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
||||
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
||||
client: clientOverride,
|
||||
maxIterations,
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
runtime: defaultRuntime,
|
||||
});
|
||||
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
||||
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
||||
client: clientOverride,
|
||||
maxIterations,
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
runtime: defaultRuntime,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDefaultDeps(): CliDeps {
|
||||
// Default dependency bundle used by CLI commands and tests.
|
||||
return {
|
||||
sendMessage,
|
||||
sendMessageWeb,
|
||||
waitForFinalStatus,
|
||||
assertProvider,
|
||||
createClient,
|
||||
monitorTwilio,
|
||||
listRecentMessages,
|
||||
ensurePortAvailable,
|
||||
startWebhook,
|
||||
waitForever,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
getTailnetHostname,
|
||||
readEnv,
|
||||
findWhatsappSenderSid,
|
||||
updateWebhook,
|
||||
handlePortError,
|
||||
monitorWebProvider,
|
||||
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
||||
if (/^https?:\/\//i.test(source)) return source;
|
||||
const hosted = await ensureMediaHosted(source, {
|
||||
startServer: serveMedia,
|
||||
runtime,
|
||||
});
|
||||
return hosted.url;
|
||||
},
|
||||
};
|
||||
// Default dependency bundle used by CLI commands and tests.
|
||||
return {
|
||||
sendMessage,
|
||||
sendMessageWeb,
|
||||
waitForFinalStatus,
|
||||
assertProvider,
|
||||
createClient,
|
||||
monitorTwilio,
|
||||
listRecentMessages,
|
||||
ensurePortAvailable,
|
||||
startWebhook,
|
||||
waitForever,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
getTailnetHostname,
|
||||
readEnv,
|
||||
findWhatsappSenderSid,
|
||||
updateWebhook,
|
||||
handlePortError,
|
||||
monitorWebProvider,
|
||||
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
||||
if (/^https?:\/\//i.test(source)) return source;
|
||||
const hosted = await ensureMediaHosted(source, {
|
||||
startServer: serveMedia,
|
||||
runtime,
|
||||
});
|
||||
return hosted.url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
||||
// Log the configured Twilio sender for clarity in CLI output.
|
||||
const env = readEnv(runtime);
|
||||
runtime.log(
|
||||
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
||||
);
|
||||
// Log the configured Twilio sender for clarity in CLI output.
|
||||
const env = readEnv(runtime);
|
||||
runtime.log(
|
||||
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
||||
);
|
||||
}
|
||||
|
||||
export { logWebSelfId };
|
||||
|
||||
+92
-52
@@ -14,11 +14,11 @@ const waitForever = vi.fn();
|
||||
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||
@@ -27,63 +27,103 @@ vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
|
||||
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||
vi.mock("../provider-web.js", () => ({
|
||||
loginWeb,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
loginWeb,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
}));
|
||||
vi.mock("./deps.js", () => ({
|
||||
createDefaultDeps: () => ({ waitForever }),
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
createDefaultDeps: () => ({ waitForever }),
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
}));
|
||||
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
describe("cli program", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("runs send with required options", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(sendCommand).toHaveBeenCalled();
|
||||
});
|
||||
it("runs send with required options", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(sendCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid relay provider", async () => {
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"--provider must be auto, web, or twilio",
|
||||
);
|
||||
});
|
||||
it("rejects invalid relay provider", async () => {
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"--provider must be auto, web, or twilio",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to twilio when web relay fails", async () => {
|
||||
pickProvider.mockResolvedValue("web");
|
||||
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(ensureTwilioEnv).toHaveBeenCalled();
|
||||
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
|
||||
});
|
||||
it("falls back to twilio when web relay fails", async () => {
|
||||
pickProvider.mockResolvedValue("web");
|
||||
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("exit");
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(ensureTwilioEnv).not.toHaveBeenCalled();
|
||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs relay tmux attach command", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||
"pnpm warelay relay --verbose",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("runs relay tmux attach command", async () => {
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||
true;
|
||||
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||
"pnpm warelay relay --verbose",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||
originalIsTTY;
|
||||
});
|
||||
|
||||
it("runs relay heartbeat command", async () => {
|
||||
pickProvider.mockResolvedValue("web");
|
||||
monitorWebProvider.mockResolvedValue(undefined);
|
||||
const originalExit = runtime.exit;
|
||||
runtime.exit = vi.fn();
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:heartbeat"], { from: "user" });
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(monitorWebProvider).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
runtime,
|
||||
undefined,
|
||||
{ replyHeartbeatNow: true },
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
runtime.exit = originalExit;
|
||||
});
|
||||
|
||||
it("runs relay heartbeat tmux helper", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:heartbeat:tmux"], { from: "user" });
|
||||
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||
"pnpm warelay relay --verbose --heartbeat-now",
|
||||
shouldAttach,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+595
-282
@@ -3,334 +3,647 @@ import { Command } from "commander";
|
||||
import { sendCommand } from "../commands/send.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { webhookCommand } from "../commands/webhook.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { ensureTwilioEnv } from "../env.js";
|
||||
import { danger, info, setVerbose, setYes, warn } from "../globals.js";
|
||||
import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js";
|
||||
import { danger, info, setVerbose, setYes } from "../globals.js";
|
||||
import { getResolvedLoggerSettings } from "../logging.js";
|
||||
import {
|
||||
loginWeb,
|
||||
logoutWeb,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
resolveHeartbeatRecipients,
|
||||
runWebHeartbeatOnce,
|
||||
type WebMonitorTuning,
|
||||
} from "../provider-web.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { Provider } from "../utils.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
createDefaultDeps,
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
resolveHeartbeatSeconds,
|
||||
resolveReconnectPolicy,
|
||||
} from "../web/reconnect.js";
|
||||
import {
|
||||
createDefaultDeps,
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
} from "./deps.js";
|
||||
import { spawnRelayTmux } from "./relay_tmux.js";
|
||||
|
||||
export function buildProgram() {
|
||||
const program = new Command();
|
||||
const PROGRAM_VERSION = VERSION;
|
||||
const TAGLINE =
|
||||
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
||||
const program = new Command();
|
||||
const PROGRAM_VERSION = VERSION;
|
||||
const TAGLINE =
|
||||
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
||||
|
||||
program
|
||||
.name("warelay")
|
||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||
.version(PROGRAM_VERSION);
|
||||
program
|
||||
.name("warelay")
|
||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||
.version(PROGRAM_VERSION);
|
||||
|
||||
const formatIntroLine = (version: string, rich = true) => {
|
||||
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
||||
return rich && chalk.level > 0
|
||||
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
||||
: base;
|
||||
};
|
||||
const formatIntroLine = (version: string, rich = true) => {
|
||||
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
||||
return rich && chalk.level > 0
|
||||
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
||||
: base;
|
||||
};
|
||||
|
||||
program.configureHelp({
|
||||
optionTerm: (option) => chalk.yellow(option.flags),
|
||||
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
||||
});
|
||||
program.configureHelp({
|
||||
optionTerm: (option) => chalk.yellow(option.flags),
|
||||
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
||||
});
|
||||
|
||||
program.configureOutput({
|
||||
writeOut: (str) => {
|
||||
const colored = str
|
||||
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
||||
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
||||
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
||||
process.stdout.write(colored);
|
||||
},
|
||||
writeErr: (str) => process.stderr.write(str),
|
||||
outputError: (str, write) => write(chalk.red(str)),
|
||||
});
|
||||
program.configureOutput({
|
||||
writeOut: (str) => {
|
||||
const colored = str
|
||||
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
||||
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
||||
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
||||
process.stdout.write(colored);
|
||||
},
|
||||
writeErr: (str) => process.stderr.write(str),
|
||||
outputError: (str, write) => write(chalk.red(str)),
|
||||
});
|
||||
|
||||
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
||||
console.log(formatIntroLine(PROGRAM_VERSION));
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
||||
console.log(formatIntroLine(PROGRAM_VERSION));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||
const examples = [
|
||||
[
|
||||
"warelay login --verbose",
|
||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||
],
|
||||
[
|
||||
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
||||
"Send via your web session and print JSON result.",
|
||||
],
|
||||
[
|
||||
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
||||
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
||||
],
|
||||
[
|
||||
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
||||
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
||||
],
|
||||
[
|
||||
"warelay status --limit 10 --lookback 60 --json",
|
||||
"Show last 10 messages from the past hour as JSON.",
|
||||
],
|
||||
] as const;
|
||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||
const examples = [
|
||||
[
|
||||
"warelay login --verbose",
|
||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||
],
|
||||
[
|
||||
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
||||
"Send via your web session and print JSON result.",
|
||||
],
|
||||
[
|
||||
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
||||
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
||||
],
|
||||
[
|
||||
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
||||
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
||||
],
|
||||
[
|
||||
"warelay status --limit 10 --lookback 60 --json",
|
||||
"Show last 10 messages from the past hour as JSON.",
|
||||
],
|
||||
] as const;
|
||||
|
||||
const fmtExamples = examples
|
||||
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
||||
.join("\n");
|
||||
const fmtExamples = examples
|
||||
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
||||
.join("\n");
|
||||
|
||||
program.addHelpText(
|
||||
"afterAll",
|
||||
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
||||
);
|
||||
program.addHelpText(
|
||||
"afterAll",
|
||||
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
||||
);
|
||||
|
||||
program
|
||||
.command("login")
|
||||
.description("Link your personal WhatsApp via QR (web provider)")
|
||||
.option("--verbose", "Verbose connection logs", false)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
try {
|
||||
await loginWeb(Boolean(opts.verbose));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command("login")
|
||||
.description("Link your personal WhatsApp via QR (web provider)")
|
||||
.option("--verbose", "Verbose connection logs", false)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
try {
|
||||
await loginWeb(Boolean(opts.verbose));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("send")
|
||||
.description("Send a WhatsApp message")
|
||||
.requiredOption(
|
||||
"-t, --to <number>",
|
||||
"Recipient number in E.164 (e.g. +15551234567)",
|
||||
)
|
||||
.requiredOption("-m, --message <text>", "Message body")
|
||||
.option(
|
||||
"--media <path-or-url>",
|
||||
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
||||
)
|
||||
.option(
|
||||
"--serve-media",
|
||||
"For Twilio: start a temporary media server if webhook is not running",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"-w, --wait <seconds>",
|
||||
"Wait for delivery status (0 to skip)",
|
||||
"20",
|
||||
)
|
||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||
.option("--dry-run", "Print payload and skip sending", false)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
program
|
||||
.command("logout")
|
||||
.description("Clear cached WhatsApp Web credentials")
|
||||
.action(async () => {
|
||||
try {
|
||||
await logoutWeb(defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("send")
|
||||
.description("Send a WhatsApp message")
|
||||
.requiredOption(
|
||||
"-t, --to <number>",
|
||||
"Recipient number in E.164 (e.g. +15551234567)",
|
||||
)
|
||||
.requiredOption("-m, --message <text>", "Message body")
|
||||
.option(
|
||||
"--media <path-or-url>",
|
||||
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
||||
)
|
||||
.option(
|
||||
"--serve-media",
|
||||
"For Twilio: start a temporary media server if webhook is not running",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"-w, --wait <seconds>",
|
||||
"Wait for delivery status (0 to skip)",
|
||||
"20",
|
||||
)
|
||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||
.option("--dry-run", "Print payload and skip sending", false)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
|
||||
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
|
||||
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
|
||||
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await sendCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await sendCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay")
|
||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
||||
.option(
|
||||
"-l, --lookback <minutes>",
|
||||
"Initial lookback window for twilio mode",
|
||||
"5",
|
||||
)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
program
|
||||
.command("heartbeat")
|
||||
.description("Trigger a heartbeat poll once (web provider, no tmux)")
|
||||
.option("--provider <provider>", "auto | web", "auto")
|
||||
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
||||
.option(
|
||||
"--session-id <id>",
|
||||
"Force a session id for this heartbeat (resumes a specific Claude session)",
|
||||
)
|
||||
.option(
|
||||
"--all",
|
||||
"Send heartbeat to all active sessions (or allowFrom entries when none)",
|
||||
false,
|
||||
)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
warelay heartbeat # uses web session + first allowFrom contact
|
||||
warelay heartbeat --verbose # prints detailed heartbeat logs
|
||||
warelay heartbeat --to +1555123 # override destination
|
||||
warelay heartbeat --session-id <uuid> --to +1555123 # resume a specific session
|
||||
warelay heartbeat --all # send to every active session recipient or allowFrom entry`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const cfg = loadConfig();
|
||||
const allowAll = Boolean(opts.all);
|
||||
const resolution = resolveHeartbeatRecipients(cfg, {
|
||||
to: opts.to,
|
||||
all: allowAll,
|
||||
});
|
||||
if (
|
||||
!opts.to &&
|
||||
!allowAll &&
|
||||
resolution.source === "session-ambiguous" &&
|
||||
resolution.recipients.length > 1
|
||||
) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Multiple active sessions found (${resolution.recipients.join(", ")}). Pass --to <E.164> or --all to send to all.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const recipients = resolution.recipients;
|
||||
if (!recipients || recipients.length === 0) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"No destination found. Add inbound.allowFrom numbers or pass --to <E.164>.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const providerPref = String(opts.provider ?? "auto");
|
||||
if (!["auto", "web"].includes(providerPref)) {
|
||||
defaultRuntime.error("--provider must be auto or web");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const provider = await pickProvider(providerPref as "auto" | "web");
|
||||
if (provider !== "web") {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"Heartbeat is only supported for the web provider. Link with `warelay login --verbose`.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
try {
|
||||
for (const to of recipients) {
|
||||
await runWebHeartbeatOnce({
|
||||
to,
|
||||
verbose: Boolean(opts.verbose),
|
||||
runtime: defaultRuntime,
|
||||
sessionId: opts.sessionId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay")
|
||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
||||
.option(
|
||||
"-l, --lookback <minutes>",
|
||||
"Initial lookback window for twilio mode",
|
||||
"5",
|
||||
)
|
||||
.option(
|
||||
"--web-heartbeat <seconds>",
|
||||
"Heartbeat interval for web relay health logs (seconds)",
|
||||
)
|
||||
.option(
|
||||
"--web-retries <count>",
|
||||
"Max consecutive web reconnect attempts before exit (0 = unlimited)",
|
||||
)
|
||||
.option(
|
||||
"--web-retry-initial <ms>",
|
||||
"Initial reconnect backoff for web relay (ms)",
|
||||
)
|
||||
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
|
||||
.option(
|
||||
"--heartbeat-now",
|
||||
"Run a heartbeat immediately when relay starts (web provider)",
|
||||
false,
|
||||
)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
warelay relay # auto: web if logged-in, else twilio poll
|
||||
warelay relay --provider web # force personal web session
|
||||
warelay relay --provider twilio # force twilio poll
|
||||
warelay relay --provider twilio --interval 2 --lookback 30
|
||||
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
|
||||
`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const providerPref = String(opts.provider ?? "auto");
|
||||
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
||||
defaultRuntime.error("Interval must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
||||
defaultRuntime.error("Lookback must be >= 0 minutes");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
|
||||
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
|
||||
const providerPref = String(opts.provider ?? "auto");
|
||||
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
const webHeartbeat =
|
||||
opts.webHeartbeat !== undefined
|
||||
? Number.parseInt(String(opts.webHeartbeat), 10)
|
||||
: undefined;
|
||||
const webRetries =
|
||||
opts.webRetries !== undefined
|
||||
? Number.parseInt(String(opts.webRetries), 10)
|
||||
: undefined;
|
||||
const webRetryInitial =
|
||||
opts.webRetryInitial !== undefined
|
||||
? Number.parseInt(String(opts.webRetryInitial), 10)
|
||||
: undefined;
|
||||
const webRetryMax =
|
||||
opts.webRetryMax !== undefined
|
||||
? Number.parseInt(String(opts.webRetryMax), 10)
|
||||
: undefined;
|
||||
const heartbeatNow = Boolean(opts.heartbeatNow);
|
||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
||||
defaultRuntime.error("Interval must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
||||
defaultRuntime.error("Lookback must be >= 0 minutes");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webHeartbeat !== undefined &&
|
||||
(Number.isNaN(webHeartbeat) || webHeartbeat <= 0)
|
||||
) {
|
||||
defaultRuntime.error("--web-heartbeat must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webRetries !== undefined &&
|
||||
(Number.isNaN(webRetries) || webRetries < 0)
|
||||
) {
|
||||
defaultRuntime.error("--web-retries must be >= 0");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webRetryInitial !== undefined &&
|
||||
(Number.isNaN(webRetryInitial) || webRetryInitial <= 0)
|
||||
) {
|
||||
defaultRuntime.error("--web-retry-initial must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webRetryMax !== undefined &&
|
||||
(Number.isNaN(webRetryMax) || webRetryMax <= 0)
|
||||
) {
|
||||
defaultRuntime.error("--web-retry-max must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webRetryMax !== undefined &&
|
||||
webRetryInitial !== undefined &&
|
||||
webRetryMax < webRetryInitial
|
||||
) {
|
||||
defaultRuntime.error("--web-retry-max must be >= --web-retry-initial");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||
const webTuning: WebMonitorTuning = {};
|
||||
if (webHeartbeat !== undefined) webTuning.heartbeatSeconds = webHeartbeat;
|
||||
if (heartbeatNow) webTuning.replyHeartbeatNow = true;
|
||||
const reconnect: WebMonitorTuning["reconnect"] = {};
|
||||
if (webRetries !== undefined) reconnect.maxAttempts = webRetries;
|
||||
if (webRetryInitial !== undefined) reconnect.initialMs = webRetryInitial;
|
||||
if (webRetryMax !== undefined) reconnect.maxMs = webRetryMax;
|
||||
if (Object.keys(reconnect).length > 0) {
|
||||
webTuning.reconnect = reconnect;
|
||||
}
|
||||
|
||||
if (provider === "web") {
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
try {
|
||||
await monitorWebProvider(Boolean(opts.verbose));
|
||||
return;
|
||||
} catch (err) {
|
||||
if (providerPref === "auto") {
|
||||
defaultRuntime.error(
|
||||
warn("Web session unavailable; falling back to twilio."),
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||
|
||||
ensureTwilioEnv();
|
||||
logTwilioFrom();
|
||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||
});
|
||||
if (provider === "web") {
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
const cfg = loadConfig();
|
||||
const effectiveHeartbeat = resolveHeartbeatSeconds(
|
||||
cfg,
|
||||
webTuning.heartbeatSeconds,
|
||||
);
|
||||
const effectivePolicy = resolveReconnectPolicy(
|
||||
cfg,
|
||||
webTuning.reconnect,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await monitorWebProvider(
|
||||
Boolean(opts.verbose),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
defaultRuntime,
|
||||
undefined,
|
||||
webTuning,
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Web relay failed: ${String(err)}. Not falling back; re-link with 'warelay login --provider web'.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Show recent WhatsApp messages (sent and received)")
|
||||
.option("-l, --limit <count>", "Number of messages to show", "20")
|
||||
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
ensureTwilioEnv();
|
||||
logTwilioFrom();
|
||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay:heartbeat")
|
||||
.description(
|
||||
"Run relay with an immediate heartbeat (no tmux); requires web provider",
|
||||
)
|
||||
.option("--provider <provider>", "auto | web", "auto")
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
|
||||
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
|
||||
const providerPref = String(opts.provider ?? "auto");
|
||||
if (!["auto", "web"].includes(providerPref)) {
|
||||
defaultRuntime.error("--provider must be auto or web");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const provider = await pickProvider(providerPref as "auto" | "web");
|
||||
if (provider !== "web") {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"Heartbeat relay is only supported for the web provider. Link with `warelay login --verbose`.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
const cfg = loadConfig();
|
||||
const effectiveHeartbeat = resolveHeartbeatSeconds(cfg, undefined);
|
||||
const effectivePolicy = resolveReconnectPolicy(cfg, undefined);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await monitorWebProvider(
|
||||
Boolean(opts.verbose),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
defaultRuntime,
|
||||
undefined,
|
||||
{ replyHeartbeatNow: true },
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Web relay failed: ${String(err)}. Re-link with 'warelay login --provider web'.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Show recent WhatsApp messages (sent and received)")
|
||||
.option("-l, --limit <count>", "Number of messages to show", "20")
|
||||
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
warelay status # last 20 msgs in past 4h
|
||||
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
||||
warelay status --json --limit 50 # machine-readable output`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await statusCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await statusCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("webhook")
|
||||
.description(
|
||||
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
||||
)
|
||||
.option("-p, --port <port>", "Port to listen on", "42873")
|
||||
.option("-r, --reply <text>", "Optional auto-reply text")
|
||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||
.option(
|
||||
"--ingress <mode>",
|
||||
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
||||
"tailscale",
|
||||
)
|
||||
.option("--verbose", "Log inbound and auto-replies", false)
|
||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||
.option("--dry-run", "Print planned actions without starting server", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
program
|
||||
.command("webhook")
|
||||
.description(
|
||||
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
||||
)
|
||||
.option("-p, --port <port>", "Port to listen on", "42873")
|
||||
.option("-r, --reply <text>", "Optional auto-reply text")
|
||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||
.option(
|
||||
"--ingress <mode>",
|
||||
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
||||
"tailscale",
|
||||
)
|
||||
.option("--verbose", "Log inbound and auto-replies", false)
|
||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||
.option("--dry-run", "Print planned actions without starting server", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
warelay webhook # ingress=tailscale (funnel + Twilio update)
|
||||
warelay webhook --ingress none # local-only server (no funnel / no Twilio update)
|
||||
warelay webhook --port 45000 # pick a high, less-colliding port
|
||||
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
||||
)
|
||||
// istanbul ignore next
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
setYes(Boolean(opts.yes));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||
if (!server) {
|
||||
defaultRuntime.log(
|
||||
info("Webhook dry-run complete; no server started."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.on("SIGINT", () => {
|
||||
server.close(() => {
|
||||
console.log("\n👋 Webhook stopped");
|
||||
defaultRuntime.exit(0);
|
||||
});
|
||||
});
|
||||
await deps.waitForever();
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
)
|
||||
// istanbul ignore next
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
setYes(Boolean(opts.yes));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||
if (!server) {
|
||||
defaultRuntime.log(
|
||||
info("Webhook dry-run complete; no server started."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.on("SIGINT", () => {
|
||||
server.close(() => {
|
||||
console.log("\n👋 Webhook stopped");
|
||||
defaultRuntime.exit(0);
|
||||
});
|
||||
});
|
||||
await deps.waitForever();
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay:tmux")
|
||||
.description(
|
||||
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const session = await spawnRelayTmux(
|
||||
"pnpm warelay relay --verbose",
|
||||
true,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Failed to start relay tmux session: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command("relay:tmux")
|
||||
.description(
|
||||
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||
const session = await spawnRelayTmux(
|
||||
"pnpm warelay relay --verbose",
|
||||
shouldAttach,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
shouldAttach
|
||||
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`
|
||||
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose"); attach manually with "tmux attach -t ${session}"`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Failed to start relay tmux session: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay:tmux:attach")
|
||||
.description(
|
||||
"Attach to the existing warelay-relay tmux session (no restart)",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command("relay:tmux:attach")
|
||||
.description(
|
||||
"Attach to the existing warelay-relay tmux session (no restart)",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
if (!process.stdout.isTTY) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"Cannot attach: stdout is not a TTY. Run this in a terminal or use 'tmux attach -t warelay-relay' manually.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
return program;
|
||||
program
|
||||
.command("relay:heartbeat:tmux")
|
||||
.description(
|
||||
"Run relay --verbose with an immediate heartbeat inside tmux (session warelay-relay), then attach",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||
const session = await spawnRelayTmux(
|
||||
"pnpm warelay relay --verbose --heartbeat-now",
|
||||
shouldAttach,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
shouldAttach
|
||||
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")`
|
||||
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now"); attach manually with "tmux attach -t ${session}"`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Failed to start relay tmux session with heartbeat: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
+31
-31
@@ -3,47 +3,47 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { isYes, setVerbose, setYes } from "../globals.js";
|
||||
|
||||
vi.mock("node:readline/promises", () => {
|
||||
const question = vi.fn<[], Promise<string>>();
|
||||
const close = vi.fn();
|
||||
const createInterface = vi.fn(() => ({ question, close }));
|
||||
return { default: { createInterface } };
|
||||
const question = vi.fn<[], Promise<string>>();
|
||||
const close = vi.fn();
|
||||
const createInterface = vi.fn(() => ({ question, close }));
|
||||
return { default: { createInterface } };
|
||||
});
|
||||
|
||||
type ReadlineMock = {
|
||||
default: {
|
||||
createInterface: () => {
|
||||
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
default: {
|
||||
createInterface: () => {
|
||||
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const { promptYesNo } = await import("./prompt.js");
|
||||
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
||||
|
||||
describe("promptYesNo", () => {
|
||||
it("returns true when global --yes is set", async () => {
|
||||
setYes(true);
|
||||
setVerbose(false);
|
||||
const result = await promptYesNo("Continue?");
|
||||
expect(result).toBe(true);
|
||||
expect(isYes()).toBe(true);
|
||||
});
|
||||
it("returns true when global --yes is set", async () => {
|
||||
setYes(true);
|
||||
setVerbose(false);
|
||||
const result = await promptYesNo("Continue?");
|
||||
expect(result).toBe(true);
|
||||
expect(isYes()).toBe(true);
|
||||
});
|
||||
|
||||
it("asks the question and respects default", async () => {
|
||||
setYes(false);
|
||||
setVerbose(false);
|
||||
const { question: questionMock } = readline.default.createInterface();
|
||||
questionMock.mockResolvedValueOnce("");
|
||||
const resultDefaultYes = await promptYesNo("Continue?", true);
|
||||
expect(resultDefaultYes).toBe(true);
|
||||
it("asks the question and respects default", async () => {
|
||||
setYes(false);
|
||||
setVerbose(false);
|
||||
const { question: questionMock } = readline.default.createInterface();
|
||||
questionMock.mockResolvedValueOnce("");
|
||||
const resultDefaultYes = await promptYesNo("Continue?", true);
|
||||
expect(resultDefaultYes).toBe(true);
|
||||
|
||||
questionMock.mockResolvedValueOnce("n");
|
||||
const resultNo = await promptYesNo("Continue?", true);
|
||||
expect(resultNo).toBe(false);
|
||||
questionMock.mockResolvedValueOnce("n");
|
||||
const resultNo = await promptYesNo("Continue?", true);
|
||||
expect(resultNo).toBe(false);
|
||||
|
||||
questionMock.mockResolvedValueOnce("y");
|
||||
const resultYes = await promptYesNo("Continue?", false);
|
||||
expect(resultYes).toBe(true);
|
||||
});
|
||||
questionMock.mockResolvedValueOnce("y");
|
||||
const resultYes = await promptYesNo("Continue?", false);
|
||||
expect(resultYes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-13
@@ -4,18 +4,18 @@ import readline from "node:readline/promises";
|
||||
import { isVerbose, isYes } from "../globals.js";
|
||||
|
||||
export async function promptYesNo(
|
||||
question: string,
|
||||
defaultYes = false,
|
||||
question: string,
|
||||
defaultYes = false,
|
||||
): Promise<boolean> {
|
||||
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
||||
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
||||
if (isYes()) return true;
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||
const answer = (await rl.question(`${question}${suffix}`))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
rl.close();
|
||||
if (!answer) return defaultYes;
|
||||
return answer.startsWith("y");
|
||||
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
||||
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
||||
if (isYes()) return true;
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||
const answer = (await rl.question(`${question}${suffix}`))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
rl.close();
|
||||
if (!answer) return defaultYes;
|
||||
return answer.startsWith("y");
|
||||
}
|
||||
|
||||
+27
-26
@@ -2,43 +2,44 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
|
||||
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
||||
vi.hoisted(() => {
|
||||
return {
|
||||
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
||||
pickProvider: vi.fn().mockResolvedValue("web"),
|
||||
logWebSelfId: vi.fn(),
|
||||
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
vi.hoisted(() => {
|
||||
return {
|
||||
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
||||
pickProvider: vi.fn().mockResolvedValue("web"),
|
||||
logWebSelfId: vi.fn(),
|
||||
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../provider-web.js", () => ({
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
logWebSelfId,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
logWebSelfId,
|
||||
}));
|
||||
|
||||
vi.mock("../twilio/monitor.js", () => ({
|
||||
monitorTwilio,
|
||||
monitorTwilio,
|
||||
}));
|
||||
|
||||
import { buildProgram } from "./program.js";
|
||||
|
||||
describe("CLI relay command (e2e-ish)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("runs relay in web mode without crashing", async () => {
|
||||
const program = buildProgram();
|
||||
program.exitOverride(); // throw instead of exiting process on error
|
||||
it("runs relay in web mode without crashing", async () => {
|
||||
const program = buildProgram();
|
||||
program.exitOverride(); // throw instead of exiting process on error
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
||||
).resolves.toBeInstanceOf(Object);
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
||||
).resolves.toBeInstanceOf(Object);
|
||||
|
||||
expect(pickProvider).toHaveBeenCalledWith("web");
|
||||
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
||||
expect(monitorWebProvider).toHaveBeenCalledWith(false);
|
||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(pickProvider).toHaveBeenCalledWith("web");
|
||||
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
||||
expect(monitorWebProvider).toHaveBeenCalledTimes(1);
|
||||
expect(monitorWebProvider.mock.calls[0][0]).toBe(false);
|
||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+33
-33
@@ -3,45 +3,45 @@ import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
proc.emit("exit", 0);
|
||||
});
|
||||
proc.kill = vi.fn();
|
||||
return proc;
|
||||
});
|
||||
return { spawn };
|
||||
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
proc.emit("exit", 0);
|
||||
});
|
||||
proc.kill = vi.fn();
|
||||
return proc;
|
||||
});
|
||||
return { spawn };
|
||||
});
|
||||
|
||||
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
describe("spawnRelayTmux", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("kills old session, starts new one, and attaches", async () => {
|
||||
const session = await spawnRelayTmux("echo hi", true, true);
|
||||
expect(session).toBe("warelay-relay");
|
||||
const spawnMock = spawn as unknown as vi.Mock;
|
||||
expect(spawnMock.mock.calls.length).toBe(3);
|
||||
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
||||
expect(calls[0][0]).toBe("tmux"); // kill-session
|
||||
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
||||
expect(calls[2][1][0]).toBe("attach-session");
|
||||
});
|
||||
it("kills old session, starts new one, and attaches", async () => {
|
||||
const session = await spawnRelayTmux("echo hi", true, true);
|
||||
expect(session).toBe("warelay-relay");
|
||||
const spawnMock = spawn as unknown as vi.Mock;
|
||||
expect(spawnMock.mock.calls.length).toBe(3);
|
||||
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
||||
expect(calls[0][0]).toBe("tmux"); // kill-session
|
||||
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
||||
expect(calls[2][1][0]).toBe("attach-session");
|
||||
});
|
||||
|
||||
it("can skip attach", async () => {
|
||||
await spawnRelayTmux("echo hi", false, true);
|
||||
const spawnMock = spawn as unknown as vi.Mock;
|
||||
const hasAttach = spawnMock.mock.calls.some(
|
||||
(c) =>
|
||||
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
||||
);
|
||||
expect(hasAttach).toBe(false);
|
||||
});
|
||||
it("can skip attach", async () => {
|
||||
await spawnRelayTmux("echo hi", false, true);
|
||||
const spawnMock = spawn as unknown as vi.Mock;
|
||||
const hasAttach = spawnMock.mock.calls.some(
|
||||
(c) =>
|
||||
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
||||
);
|
||||
expect(hasAttach).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-38
@@ -3,48 +3,48 @@ import { spawn } from "node:child_process";
|
||||
const SESSION = "warelay-relay";
|
||||
|
||||
export async function spawnRelayTmux(
|
||||
cmd = "pnpm warelay relay --verbose",
|
||||
attach = true,
|
||||
restart = true,
|
||||
cmd = "pnpm warelay relay --verbose",
|
||||
attach = true,
|
||||
restart = true,
|
||||
) {
|
||||
if (restart) {
|
||||
await killSession(SESSION);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tmux exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
if (restart) {
|
||||
await killSession(SESSION);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tmux exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (attach) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tmux attach exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
if (attach) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tmux attach exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return SESSION;
|
||||
return SESSION;
|
||||
}
|
||||
|
||||
async function killSession(name: string) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn("tmux", ["kill-session", "-t", name], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("exit", () => resolve());
|
||||
child.on("error", () => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn("tmux", ["kill-session", "-t", name], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("exit", () => resolve());
|
||||
child.on("error", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
+10
-10
@@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { waitForever } from "./wait.js";
|
||||
|
||||
describe("waitForever", () => {
|
||||
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
||||
const promise = waitForever();
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
1_000_000,
|
||||
);
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
||||
const promise = waitForever();
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
1_000_000,
|
||||
);
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
export function waitForever() {
|
||||
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
||||
const interval = setInterval(() => {}, 1_000_000);
|
||||
interval.unref();
|
||||
return new Promise<void>(() => {
|
||||
/* never resolve */
|
||||
});
|
||||
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
||||
const interval = setInterval(() => {}, 1_000_000);
|
||||
interval.unref();
|
||||
return new Promise<void>(() => {
|
||||
/* never resolve */
|
||||
});
|
||||
}
|
||||
|
||||
+126
-126
@@ -5,141 +5,141 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { sendCommand } from "./send.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const baseDeps = {
|
||||
assertProvider: vi.fn(),
|
||||
sendMessageWeb: vi.fn(),
|
||||
resolveTwilioMediaUrl: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
assertProvider: vi.fn(),
|
||||
sendMessageWeb: vi.fn(),
|
||||
resolveTwilioMediaUrl: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as unknown as CliDeps;
|
||||
|
||||
describe("sendCommand", () => {
|
||||
it("validates wait and poll", async () => {
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "-1",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||
it("validates wait and poll", async () => {
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "-1",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "0",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Poll must be > 0 seconds");
|
||||
});
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "0",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Poll must be > 0 seconds");
|
||||
});
|
||||
|
||||
it("handles web dry-run and warns on wait", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "5",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
dryRun: true,
|
||||
media: "pic.jpg",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
it("handles web dry-run and warns on wait", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "5",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
dryRun: true,
|
||||
media: "pic.jpg",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends via web and outputs JSON", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "1",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "web"'),
|
||||
);
|
||||
});
|
||||
it("sends via web and outputs JSON", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "1",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "web"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports twilio dry-run", async () => {
|
||||
const deps = { ...baseDeps } as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
dryRun: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
it("supports twilio dry-run", async () => {
|
||||
const deps = { ...baseDeps } as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
dryRun: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends via twilio with media and skips wait when zero", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
media: "pic.jpg",
|
||||
serveMedia: true,
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||
serveMedia: true,
|
||||
runtime,
|
||||
});
|
||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "twilio"'),
|
||||
);
|
||||
});
|
||||
it("sends via twilio with media and skips wait when zero", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
media: "pic.jpg",
|
||||
serveMedia: true,
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||
serveMedia: true,
|
||||
runtime,
|
||||
});
|
||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "twilio"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+99
-99
@@ -4,109 +4,109 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { Provider } from "../utils.js";
|
||||
|
||||
export async function sendCommand(
|
||||
opts: {
|
||||
to: string;
|
||||
message: string;
|
||||
wait: string;
|
||||
poll: string;
|
||||
provider: Provider;
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
media?: string;
|
||||
serveMedia?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: {
|
||||
to: string;
|
||||
message: string;
|
||||
wait: string;
|
||||
poll: string;
|
||||
provider: Provider;
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
media?: string;
|
||||
serveMedia?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
deps.assertProvider(opts.provider);
|
||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||
deps.assertProvider(opts.provider);
|
||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||
|
||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||
throw new Error("Wait must be >= 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||
throw new Error("Poll must be > 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||
throw new Error("Wait must be >= 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||
throw new Error("Poll must be > 0 seconds");
|
||||
}
|
||||
|
||||
if (opts.provider === "web") {
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (waitSeconds !== 0) {
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
const res = await deps
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
to: opts.to,
|
||||
messageId: res.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (opts.provider === "web") {
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (waitSeconds !== 0) {
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
const res = await deps
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
to: opts.to,
|
||||
messageId: res.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaUrl: string | undefined;
|
||||
if (opts.media) {
|
||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||
serveMedia: Boolean(opts.serveMedia),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
let mediaUrl: string | undefined;
|
||||
if (opts.media) {
|
||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||
serveMedia: Boolean(opts.serveMedia),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deps.sendMessage(
|
||||
opts.to,
|
||||
opts.message,
|
||||
{ mediaUrl },
|
||||
runtime,
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "twilio",
|
||||
to: opts.to,
|
||||
sid: result?.sid ?? null,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!result) return;
|
||||
if (waitSeconds === 0) return;
|
||||
await deps.waitForFinalStatus(
|
||||
result.client,
|
||||
result.sid,
|
||||
waitSeconds,
|
||||
pollSeconds,
|
||||
runtime,
|
||||
);
|
||||
const result = await deps.sendMessage(
|
||||
opts.to,
|
||||
opts.message,
|
||||
{ mediaUrl },
|
||||
runtime,
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "twilio",
|
||||
to: opts.to,
|
||||
sid: result?.sid ?? null,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!result) return;
|
||||
if (waitSeconds === 0) return;
|
||||
await deps.waitForFinalStatus(
|
||||
result.client,
|
||||
result.sid,
|
||||
waitSeconds,
|
||||
pollSeconds,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
+31
-31
@@ -5,46 +5,46 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { statusCommand } from "./status.js";
|
||||
|
||||
vi.mock("../twilio/messages.js", () => ({
|
||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||
}));
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const deps: CliDeps = {
|
||||
listRecentMessages: vi.fn(),
|
||||
listRecentMessages: vi.fn(),
|
||||
} as unknown as CliDeps;
|
||||
|
||||
describe("statusCommand", () => {
|
||||
it("validates limit and lookback", async () => {
|
||||
await expect(
|
||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||
).rejects.toThrow("limit must be between 1 and 200");
|
||||
await expect(
|
||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||
).rejects.toThrow("lookback must be > 0 minutes");
|
||||
});
|
||||
it("validates limit and lookback", async () => {
|
||||
await expect(
|
||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||
).rejects.toThrow("limit must be between 1 and 200");
|
||||
await expect(
|
||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||
).rejects.toThrow("lookback must be > 0 minutes");
|
||||
});
|
||||
|
||||
it("prints JSON when requested", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||
await statusCommand(
|
||||
{ limit: "5", lookback: "10", json: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify([{ sid: "1" }], null, 2),
|
||||
);
|
||||
});
|
||||
it("prints JSON when requested", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||
await statusCommand(
|
||||
{ limit: "5", lookback: "10", json: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify([{ sid: "1" }], null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||
});
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||
});
|
||||
});
|
||||
|
||||
+23
-23
@@ -3,29 +3,29 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { formatMessageLine } from "../twilio/messages.js";
|
||||
|
||||
export async function statusCommand(
|
||||
opts: { limit: string; lookback: string; json?: boolean },
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: { limit: string; lookback: string; json?: boolean },
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||
throw new Error("limit must be between 1 and 200");
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||
throw new Error("lookback must be > 0 minutes");
|
||||
}
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||
throw new Error("limit must be between 1 and 200");
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||
throw new Error("lookback must be > 0 minutes");
|
||||
}
|
||||
|
||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
runtime.log("No messages found in the requested window.");
|
||||
return;
|
||||
}
|
||||
for (const m of messages) {
|
||||
runtime.log(formatMessageLine(m));
|
||||
}
|
||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
runtime.log("No messages found in the requested window.");
|
||||
return;
|
||||
}
|
||||
for (const m of messages) {
|
||||
runtime.log(formatMessageLine(m));
|
||||
}
|
||||
}
|
||||
|
||||
+60
-60
@@ -5,72 +5,72 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { upCommand } from "./up.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const makeDeps = (): CliDeps => ({
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
readEnv: vi.fn().mockReturnValue({
|
||||
whatsappFrom: "whatsapp:+1555",
|
||||
whatsappSenderSid: "WW",
|
||||
}),
|
||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
readEnv: vi.fn().mockReturnValue({
|
||||
whatsappFrom: "whatsapp:+1555",
|
||||
whatsappSenderSid: "WW",
|
||||
}),
|
||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
describe("upCommand", () => {
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it("performs dry run and returns mock data", async () => {
|
||||
runtime.log.mockClear();
|
||||
const result = await upCommand(
|
||||
{ port: "42873", path: "/cb", dryRun: true },
|
||||
makeDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would enable funnel on port 42873",
|
||||
);
|
||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||
expect(result?.senderSid).toBeUndefined();
|
||||
});
|
||||
it("performs dry run and returns mock data", async () => {
|
||||
runtime.log.mockClear();
|
||||
const result = await upCommand(
|
||||
{ port: "42873", path: "/cb", dryRun: true },
|
||||
makeDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would enable funnel on port 42873",
|
||||
);
|
||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||
expect(result?.senderSid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||
const deps = makeDeps();
|
||||
const res = await upCommand(
|
||||
{ port: "42873", path: "/hook", verbose: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||
"tailscale",
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||
expect(deps.startWebhook).toHaveBeenCalled();
|
||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"SID123",
|
||||
"https://tailnet-host/hook",
|
||||
"POST",
|
||||
runtime,
|
||||
);
|
||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||
// waiter is returned to keep the process alive in real use.
|
||||
expect(typeof res?.waiter).toBe("function");
|
||||
});
|
||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||
const deps = makeDeps();
|
||||
const res = await upCommand(
|
||||
{ port: "42873", path: "/hook", verbose: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||
"tailscale",
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||
expect(deps.startWebhook).toHaveBeenCalled();
|
||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"SID123",
|
||||
"https://tailnet-host/hook",
|
||||
"POST",
|
||||
runtime,
|
||||
);
|
||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||
// waiter is returned to keep the process alive in real use.
|
||||
expect(typeof res?.waiter).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
+55
-55
@@ -4,65 +4,65 @@ import { retryAsync } from "../infra/retry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export async function upCommand(
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
await deps.ensurePortAvailable(port);
|
||||
const env = deps.readEnv(runtime);
|
||||
if (opts.dryRun) {
|
||||
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
||||
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
||||
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
||||
const publicUrl = `https://dry-run${opts.path}`;
|
||||
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
||||
}
|
||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||
const host = await deps.getTailnetHostname();
|
||||
const publicUrl = `https://${host}${opts.path}`;
|
||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||
await deps.ensurePortAvailable(port);
|
||||
const env = deps.readEnv(runtime);
|
||||
if (opts.dryRun) {
|
||||
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
||||
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
||||
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
||||
const publicUrl = `https://dry-run${opts.path}`;
|
||||
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
||||
}
|
||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||
const host = await deps.getTailnetHostname();
|
||||
const publicUrl = `https://${host}${opts.path}`;
|
||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
undefined,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
undefined,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
|
||||
if (!deps.createClient) {
|
||||
throw new Error("Twilio client dependency missing");
|
||||
}
|
||||
const twilioClient = deps.createClient(env);
|
||||
const senderSid = await deps.findWhatsappSenderSid(
|
||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||
env.whatsappFrom,
|
||||
env.whatsappSenderSid,
|
||||
runtime,
|
||||
);
|
||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||
if (!deps.createClient) {
|
||||
throw new Error("Twilio client dependency missing");
|
||||
}
|
||||
const twilioClient = deps.createClient(env);
|
||||
const senderSid = await deps.findWhatsappSenderSid(
|
||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||
env.whatsappFrom,
|
||||
env.whatsappSenderSid,
|
||||
runtime,
|
||||
);
|
||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||
|
||||
runtime.log(
|
||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||
);
|
||||
runtime.log(
|
||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||
);
|
||||
|
||||
return { server, publicUrl, senderSid, waiter };
|
||||
return { server, publicUrl, senderSid, waiter };
|
||||
}
|
||||
|
||||
@@ -6,57 +6,57 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { webhookCommand } from "./webhook.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const deps: CliDeps = {
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
};
|
||||
|
||||
describe("webhookCommand", () => {
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it("logs dry run instead of starting server", async () => {
|
||||
runtime.log.mockClear();
|
||||
const res = await webhookCommand(
|
||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would start webhook on port 42873 path /hook",
|
||||
);
|
||||
});
|
||||
it("logs dry run instead of starting server", async () => {
|
||||
runtime.log.mockClear();
|
||||
const res = await webhookCommand(
|
||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would start webhook on port 42873 path /hook",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts webhook when valid", async () => {
|
||||
const res = await webhookCommand(
|
||||
{
|
||||
port: "42873",
|
||||
path: "/hook",
|
||||
reply: "ok",
|
||||
verbose: true,
|
||||
ingress: "none",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||
42873,
|
||||
"/hook",
|
||||
"ok",
|
||||
true,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toEqual({ server: true });
|
||||
});
|
||||
it("starts webhook when valid", async () => {
|
||||
const res = await webhookCommand(
|
||||
{
|
||||
port: "42873",
|
||||
path: "/hook",
|
||||
reply: "ok",
|
||||
verbose: true,
|
||||
ingress: "none",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||
42873,
|
||||
"/hook",
|
||||
"ok",
|
||||
true,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toEqual({ server: true });
|
||||
});
|
||||
});
|
||||
|
||||
+52
-52
@@ -4,60 +4,60 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { upCommand } from "./up.js";
|
||||
|
||||
export async function webhookCommand(
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
reply?: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
ingress?: "tailscale" | "none";
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
reply?: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
ingress?: "tailscale" | "none";
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
const ingress = opts.ingress ?? "tailscale";
|
||||
const ingress = opts.ingress ?? "tailscale";
|
||||
|
||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||
if (ingress === "tailscale") {
|
||||
const result = await upCommand(
|
||||
{
|
||||
port: opts.port,
|
||||
path: opts.path,
|
||||
verbose: opts.verbose,
|
||||
yes: opts.yes,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
return result.server;
|
||||
}
|
||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||
if (ingress === "tailscale") {
|
||||
const result = await upCommand(
|
||||
{
|
||||
port: opts.port,
|
||||
path: opts.path,
|
||||
verbose: opts.verbose,
|
||||
yes: opts.yes,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
return result.server;
|
||||
}
|
||||
|
||||
// Local-only webhook (no ingress / no Twilio update).
|
||||
await deps.ensurePortAvailable(port);
|
||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
opts.reply,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
return server;
|
||||
// Local-only webhook (no ingress / no Twilio update).
|
||||
await deps.ensurePortAvailable(port);
|
||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
opts.reply,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
+158
-104
@@ -10,124 +10,178 @@ export type ClaudeOutputFormat = "text" | "json" | "stream-json";
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
export type SessionConfig = {
|
||||
scope?: SessionScope;
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
store?: string;
|
||||
sessionArgNew?: string[];
|
||||
sessionArgResume?: string[];
|
||||
sessionArgBeforeBody?: boolean;
|
||||
scope?: SessionScope;
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
heartbeatIdleMinutes?: number;
|
||||
store?: string;
|
||||
sessionArgNew?: string[];
|
||||
sessionArgResume?: string[];
|
||||
sessionArgBeforeBody?: boolean;
|
||||
sendSystemOnce?: boolean;
|
||||
sessionIntro?: string;
|
||||
typingIntervalSeconds?: number;
|
||||
heartbeatMinutes?: number;
|
||||
};
|
||||
|
||||
export type LoggingConfig = {
|
||||
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||
file?: string;
|
||||
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||
file?: string;
|
||||
};
|
||||
|
||||
export type WebReconnectConfig = {
|
||||
initialMs?: number;
|
||||
maxMs?: number;
|
||||
factor?: number;
|
||||
jitter?: number;
|
||||
maxAttempts?: number; // 0 = unlimited
|
||||
};
|
||||
|
||||
export type WebConfig = {
|
||||
heartbeatSeconds?: number;
|
||||
reconnect?: WebReconnectConfig;
|
||||
};
|
||||
|
||||
export type WarelayConfig = {
|
||||
logging?: LoggingConfig;
|
||||
inbound?: {
|
||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||
reply?: {
|
||||
mode: ReplyMode;
|
||||
text?: string; // for mode=text, can contain {{Body}}
|
||||
command?: string[]; // for mode=command, argv with templates
|
||||
cwd?: string; // working directory for command execution
|
||||
template?: string; // prepend template string when building command/prompt
|
||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
||||
mediaUrl?: string; // optional media attachment (path or URL)
|
||||
session?: SessionConfig;
|
||||
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
||||
};
|
||||
};
|
||||
logging?: LoggingConfig;
|
||||
inbound?: {
|
||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||
transcribeAudio?: {
|
||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||
command: string[];
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
reply?: {
|
||||
mode: ReplyMode;
|
||||
text?: string; // for mode=text, can contain {{Body}}
|
||||
command?: string[]; // for mode=command, argv with templates
|
||||
cwd?: string; // working directory for command execution
|
||||
template?: string; // prepend template string when building command/prompt
|
||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
||||
mediaUrl?: string; // optional media attachment (path or URL)
|
||||
session?: SessionConfig;
|
||||
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
||||
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
|
||||
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
|
||||
heartbeatMinutes?: number; // auto-ping cadence for command mode
|
||||
};
|
||||
};
|
||||
web?: WebConfig;
|
||||
};
|
||||
|
||||
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
||||
|
||||
const ReplySchema = z
|
||||
.object({
|
||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||
text: z.string().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
template: z.string().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
bodyPrefix: z.string().optional(),
|
||||
mediaUrl: z.string().optional(),
|
||||
session: z
|
||||
.object({
|
||||
scope: z
|
||||
.union([z.literal("per-sender"), z.literal("global")])
|
||||
.optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
store: z.string().optional(),
|
||||
sessionArgNew: z.array(z.string()).optional(),
|
||||
sessionArgResume: z.array(z.string()).optional(),
|
||||
sessionArgBeforeBody: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
claudeOutputFormat: z
|
||||
.union([
|
||||
z.literal("text"),
|
||||
z.literal("json"),
|
||||
z.literal("stream-json"),
|
||||
z.undefined(),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
||||
{
|
||||
message:
|
||||
"reply.text is required for mode=text; reply.command is required for mode=command",
|
||||
},
|
||||
);
|
||||
.object({
|
||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||
text: z.string().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
template: z.string().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
bodyPrefix: z.string().optional(),
|
||||
mediaUrl: z.string().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
session: z
|
||||
.object({
|
||||
scope: z
|
||||
.union([z.literal("per-sender"), z.literal("global")])
|
||||
.optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||
store: z.string().optional(),
|
||||
sessionArgNew: z.array(z.string()).optional(),
|
||||
sessionArgResume: z.array(z.string()).optional(),
|
||||
sessionArgBeforeBody: z.boolean().optional(),
|
||||
sendSystemOnce: z.boolean().optional(),
|
||||
sessionIntro: z.string().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
heartbeatMinutes: z.number().int().nonnegative().optional(),
|
||||
claudeOutputFormat: z
|
||||
.union([
|
||||
z.literal("text"),
|
||||
z.literal("json"),
|
||||
z.literal("stream-json"),
|
||||
z.undefined(),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
||||
{
|
||||
message:
|
||||
"reply.text is required for mode=text; reply.command is required for mode=command",
|
||||
},
|
||||
);
|
||||
|
||||
const WarelaySchema = z.object({
|
||||
logging: z
|
||||
.object({
|
||||
level: z
|
||||
.union([
|
||||
z.literal("silent"),
|
||||
z.literal("fatal"),
|
||||
z.literal("error"),
|
||||
z.literal("warn"),
|
||||
z.literal("info"),
|
||||
z.literal("debug"),
|
||||
z.literal("trace"),
|
||||
])
|
||||
.optional(),
|
||||
file: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
inbound: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
reply: ReplySchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
logging: z
|
||||
.object({
|
||||
level: z
|
||||
.union([
|
||||
z.literal("silent"),
|
||||
z.literal("fatal"),
|
||||
z.literal("error"),
|
||||
z.literal("warn"),
|
||||
z.literal("info"),
|
||||
z.literal("debug"),
|
||||
z.literal("trace"),
|
||||
])
|
||||
.optional(),
|
||||
file: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
inbound: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
transcribeAudio: z
|
||||
.object({
|
||||
command: z.array(z.string()),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
reply: ReplySchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
web: z
|
||||
.object({
|
||||
heartbeatSeconds: z.number().int().positive().optional(),
|
||||
reconnect: z
|
||||
.object({
|
||||
initialMs: z.number().positive().optional(),
|
||||
maxMs: z.number().positive().optional(),
|
||||
factor: z.number().positive().optional(),
|
||||
jitter: z.number().min(0).max(1).optional(),
|
||||
maxAttempts: z.number().int().min(0).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function loadConfig(): WarelayConfig {
|
||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const validated = WarelaySchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
console.error("Invalid warelay config:");
|
||||
for (const iss of validated.error.issues) {
|
||||
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return validated.data as WarelayConfig;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
||||
return {};
|
||||
}
|
||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const validated = WarelaySchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
console.error("Invalid warelay config:");
|
||||
for (const iss of validated.error.issues) {
|
||||
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return validated.data as WarelayConfig;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest";
|
||||
import { deriveSessionKey } from "./sessions.js";
|
||||
|
||||
describe("sessions", () => {
|
||||
it("returns normalized per-sender key", () => {
|
||||
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
||||
"+1555",
|
||||
);
|
||||
});
|
||||
it("returns normalized per-sender key", () => {
|
||||
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
||||
"+1555",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to unknown when sender missing", () => {
|
||||
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
||||
});
|
||||
it("falls back to unknown when sender missing", () => {
|
||||
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
||||
});
|
||||
|
||||
it("global scope returns global", () => {
|
||||
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
||||
});
|
||||
it("global scope returns global", () => {
|
||||
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
||||
});
|
||||
});
|
||||
|
||||
+31
-27
@@ -8,49 +8,53 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
export type SessionEntry = { sessionId: string; updatedAt: number };
|
||||
export type SessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
systemSent?: boolean;
|
||||
};
|
||||
|
||||
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||
export const DEFAULT_IDLE_MINUTES = 60;
|
||||
|
||||
export function resolveStorePath(store?: string) {
|
||||
if (!store) return SESSION_STORE_DEFAULT;
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace("~", os.homedir()));
|
||||
return path.resolve(store);
|
||||
if (!store) return SESSION_STORE_DEFAULT;
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace("~", os.homedir()));
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
storePath: string,
|
||||
): Record<string, SessionEntry> {
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as Record<string, SessionEntry>;
|
||||
}
|
||||
} catch {
|
||||
// ignore missing/invalid store; we'll recreate it
|
||||
}
|
||||
return {};
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as Record<string, SessionEntry>;
|
||||
}
|
||||
} catch {
|
||||
// ignore missing/invalid store; we'll recreate it
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function saveSessionStore(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
) {
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(store, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(store, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
// Decide which session bucket to use (per-sender vs global).
|
||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||
if (scope === "global") return "global";
|
||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||
return from || "unknown";
|
||||
if (scope === "global") return "global";
|
||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||
return from || "unknown";
|
||||
}
|
||||
|
||||
+80
-80
@@ -4,94 +4,94 @@ import { ensureTwilioEnv, readEnv } from "./env.js";
|
||||
import type { RuntimeEnv } from "./runtime.js";
|
||||
|
||||
const baseEnv = {
|
||||
TWILIO_ACCOUNT_SID: "AC123",
|
||||
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
||||
TWILIO_ACCOUNT_SID: "AC123",
|
||||
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
||||
};
|
||||
|
||||
describe("env helpers", () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = {};
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = {};
|
||||
});
|
||||
|
||||
function setEnv(vars: Record<string, string | undefined>) {
|
||||
process.env = {};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
function setEnv(vars: Record<string, string | undefined>) {
|
||||
process.env = {};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
it("reads env with auth token", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
expect(cfg.accountSid).toBe("AC123");
|
||||
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
||||
if ("authToken" in cfg.auth) {
|
||||
expect(cfg.auth.authToken).toBe("token");
|
||||
} else {
|
||||
throw new Error("Expected auth token");
|
||||
}
|
||||
});
|
||||
it("reads env with auth token", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
expect(cfg.accountSid).toBe("AC123");
|
||||
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
||||
if ("authToken" in cfg.auth) {
|
||||
expect(cfg.auth.authToken).toBe("token");
|
||||
} else {
|
||||
throw new Error("Expected auth token");
|
||||
}
|
||||
});
|
||||
|
||||
it("reads env with API key/secret", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: "key",
|
||||
TWILIO_API_SECRET: "secret",
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
||||
expect(cfg.auth.apiKey).toBe("key");
|
||||
expect(cfg.auth.apiSecret).toBe("secret");
|
||||
} else {
|
||||
throw new Error("Expected API key/secret");
|
||||
}
|
||||
});
|
||||
it("reads env with API key/secret", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: "key",
|
||||
TWILIO_API_SECRET: "secret",
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
||||
expect(cfg.auth.apiKey).toBe("key");
|
||||
expect(cfg.auth.apiSecret).toBe("secret");
|
||||
} else {
|
||||
throw new Error("Expected API key/secret");
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast on invalid env", () => {
|
||||
setEnv({
|
||||
TWILIO_ACCOUNT_SID: "",
|
||||
TWILIO_WHATSAPP_FROM: "",
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => readEnv(runtime)).toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
it("fails fast on invalid env", () => {
|
||||
setEnv({
|
||||
TWILIO_ACCOUNT_SID: "",
|
||||
TWILIO_WHATSAPP_FROM: "",
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => readEnv(runtime)).toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ensureTwilioEnv passes when token present", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
||||
});
|
||||
it("ensureTwilioEnv passes when token present", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
||||
});
|
||||
|
||||
it("ensureTwilioEnv fails when missing auth", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
||||
});
|
||||
it("ensureTwilioEnv fails when missing auth", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
||||
});
|
||||
});
|
||||
|
||||
+86
-86
@@ -4,103 +4,103 @@ import { danger } from "./globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||
|
||||
export type AuthMode =
|
||||
| { accountSid: string; authToken: string }
|
||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
||||
| { accountSid: string; authToken: string }
|
||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
||||
|
||||
export type EnvConfig = {
|
||||
accountSid: string;
|
||||
whatsappFrom: string;
|
||||
whatsappSenderSid?: string;
|
||||
auth: AuthMode;
|
||||
accountSid: string;
|
||||
whatsappFrom: string;
|
||||
whatsappSenderSid?: string;
|
||||
auth: AuthMode;
|
||||
};
|
||||
|
||||
const EnvSchema = z
|
||||
.object({
|
||||
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
||||
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
||||
TWILIO_SENDER_SID: z.string().optional(),
|
||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||
TWILIO_API_KEY: z.string().optional(),
|
||||
TWILIO_API_SECRET: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
||||
});
|
||||
}
|
||||
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!val.TWILIO_AUTH_TOKEN &&
|
||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
||||
});
|
||||
}
|
||||
});
|
||||
.object({
|
||||
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
||||
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
||||
TWILIO_SENDER_SID: z.string().optional(),
|
||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||
TWILIO_API_KEY: z.string().optional(),
|
||||
TWILIO_API_SECRET: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
||||
});
|
||||
}
|
||||
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!val.TWILIO_AUTH_TOKEN &&
|
||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
||||
// Load and validate Twilio auth + sender configuration from env.
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
runtime.error("Invalid environment configuration:");
|
||||
parsed.error.issues.forEach((iss) => {
|
||||
runtime.error(`- ${iss.message}`);
|
||||
});
|
||||
runtime.exit(1);
|
||||
}
|
||||
// Load and validate Twilio auth + sender configuration from env.
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
runtime.error("Invalid environment configuration:");
|
||||
parsed.error.issues.forEach((iss) => {
|
||||
runtime.error(`- ${iss.message}`);
|
||||
});
|
||||
runtime.exit(1);
|
||||
}
|
||||
|
||||
const {
|
||||
TWILIO_ACCOUNT_SID: accountSid,
|
||||
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
||||
TWILIO_SENDER_SID: whatsappSenderSid,
|
||||
TWILIO_AUTH_TOKEN: authToken,
|
||||
TWILIO_API_KEY: apiKey,
|
||||
TWILIO_API_SECRET: apiSecret,
|
||||
} = parsed.data;
|
||||
const {
|
||||
TWILIO_ACCOUNT_SID: accountSid,
|
||||
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
||||
TWILIO_SENDER_SID: whatsappSenderSid,
|
||||
TWILIO_AUTH_TOKEN: authToken,
|
||||
TWILIO_API_KEY: apiKey,
|
||||
TWILIO_API_SECRET: apiSecret,
|
||||
} = parsed.data;
|
||||
|
||||
let auth: AuthMode;
|
||||
if (apiKey && apiSecret) {
|
||||
auth = { accountSid, apiKey, apiSecret };
|
||||
} else if (authToken) {
|
||||
auth = { accountSid, authToken };
|
||||
} else {
|
||||
runtime.error("Missing Twilio auth configuration");
|
||||
runtime.exit(1);
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
let auth: AuthMode;
|
||||
if (apiKey && apiSecret) {
|
||||
auth = { accountSid, apiKey, apiSecret };
|
||||
} else if (authToken) {
|
||||
auth = { accountSid, authToken };
|
||||
} else {
|
||||
runtime.error("Missing Twilio auth configuration");
|
||||
runtime.exit(1);
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
return {
|
||||
accountSid,
|
||||
whatsappFrom,
|
||||
whatsappSenderSid,
|
||||
auth,
|
||||
};
|
||||
return {
|
||||
accountSid,
|
||||
whatsappFrom,
|
||||
whatsappSenderSid,
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||
const missing = required.filter((k) => !process.env[k]);
|
||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||
const hasKey = Boolean(
|
||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||
);
|
||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||
runtime.error(
|
||||
danger(
|
||||
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||
const missing = required.filter((k) => !process.env[k]);
|
||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||
const hasKey = Boolean(
|
||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||
);
|
||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||
runtime.error(
|
||||
danger(
|
||||
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-21
@@ -2,28 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";
|
||||
|
||||
describe("globals", () => {
|
||||
afterEach(() => {
|
||||
setVerbose(false);
|
||||
setYes(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
setVerbose(false);
|
||||
setYes(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("toggles verbose flag and logs when enabled", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
setVerbose(false);
|
||||
logVerbose("hidden");
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
it("toggles verbose flag and logs when enabled", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
setVerbose(false);
|
||||
logVerbose("hidden");
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
|
||||
setVerbose(true);
|
||||
logVerbose("shown");
|
||||
expect(isVerbose()).toBe(true);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
||||
});
|
||||
setVerbose(true);
|
||||
logVerbose("shown");
|
||||
expect(isVerbose()).toBe(true);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
||||
});
|
||||
|
||||
it("stores yes flag", () => {
|
||||
setYes(true);
|
||||
expect(isYes()).toBe(true);
|
||||
setYes(false);
|
||||
expect(isYes()).toBe(false);
|
||||
});
|
||||
it("stores yes flag", () => {
|
||||
setYes(true);
|
||||
expect(isYes()).toBe(true);
|
||||
setYes(false);
|
||||
expect(isYes()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -4,23 +4,23 @@ let globalVerbose = false;
|
||||
let globalYes = false;
|
||||
|
||||
export function setVerbose(v: boolean) {
|
||||
globalVerbose = v;
|
||||
globalVerbose = v;
|
||||
}
|
||||
|
||||
export function isVerbose() {
|
||||
return globalVerbose;
|
||||
return globalVerbose;
|
||||
}
|
||||
|
||||
export function logVerbose(message: string) {
|
||||
if (globalVerbose) console.log(chalk.gray(message));
|
||||
if (globalVerbose) console.log(chalk.gray(message));
|
||||
}
|
||||
|
||||
export function setYes(v: boolean) {
|
||||
globalYes = v;
|
||||
globalYes = v;
|
||||
}
|
||||
|
||||
export function isYes() {
|
||||
return globalYes;
|
||||
return globalYes;
|
||||
}
|
||||
|
||||
export const success = chalk.green;
|
||||
|
||||
+112
-112
@@ -6,134 +6,134 @@ import * as providerWeb from "./provider-web.js";
|
||||
import { defaultRuntime } from "./runtime.js";
|
||||
|
||||
vi.mock("twilio", () => {
|
||||
const { factory } = createMockTwilio();
|
||||
return { default: factory };
|
||||
const { factory } = createMockTwilio();
|
||||
return { default: factory };
|
||||
});
|
||||
|
||||
import * as index from "./index.js";
|
||||
import * as provider from "./provider-web.js";
|
||||
|
||||
beforeEach(() => {
|
||||
index.program.exitOverride();
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||
vi.clearAllMocks();
|
||||
index.program.exitOverride();
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CLI commands", () => {
|
||||
it("exposes login command", () => {
|
||||
const names = index.program.commands.map((c) => c.name());
|
||||
expect(names).toContain("login");
|
||||
});
|
||||
it("exposes login command", () => {
|
||||
const names = index.program.commands.map((c) => c.name());
|
||||
expect(names).toContain("login");
|
||||
});
|
||||
|
||||
it("send command routes to web provider", async () => {
|
||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
[
|
||||
"send",
|
||||
"--to",
|
||||
"+1555",
|
||||
"--message",
|
||||
"hi",
|
||||
"--provider",
|
||||
"web",
|
||||
"--wait",
|
||||
"0",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(sendWeb).toHaveBeenCalled();
|
||||
});
|
||||
it("send command routes to web provider", async () => {
|
||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
[
|
||||
"send",
|
||||
"--to",
|
||||
"+1555",
|
||||
"--message",
|
||||
"hi",
|
||||
"--provider",
|
||||
"web",
|
||||
"--wait",
|
||||
"0",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(sendWeb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command uses twilio path when provider=twilio", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
it("send command uses twilio path when provider=twilio", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command supports dry-run and skips sending", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
it("send command supports dry-run and skips sending", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command outputs JSON when requested", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
||||
const logSpy = vi.spyOn(defaultRuntime, "log");
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"sid": "SMJSON"'),
|
||||
);
|
||||
});
|
||||
it("send command outputs JSON when requested", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
||||
const logSpy = vi.spyOn(defaultRuntime, "log");
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"sid": "SMJSON"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("login command calls web login", async () => {
|
||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(["login"], { from: "user" });
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
it("login command calls web login", async () => {
|
||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(["login"], { from: "user" });
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("status command prints JSON", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.list
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "1",
|
||||
status: "delivered",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||
from: "a",
|
||||
to: "b",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "2",
|
||||
status: "sent",
|
||||
direction: "outbound-api",
|
||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||
from: "b",
|
||||
to: "a",
|
||||
body: "yo",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]);
|
||||
const runtime = {
|
||||
...defaultRuntime,
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await statusCommand(
|
||||
{ limit: "1", lookback: "10", json: true },
|
||||
createDefaultDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
it("status command prints JSON", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.list
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "1",
|
||||
status: "delivered",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||
from: "a",
|
||||
to: "b",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "2",
|
||||
status: "sent",
|
||||
direction: "outbound-api",
|
||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||
from: "b",
|
||||
to: "a",
|
||||
body: "yo",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]);
|
||||
const runtime = {
|
||||
...defaultRuntime,
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await statusCommand(
|
||||
{ limit: "1", lookback: "10", json: true },
|
||||
createDefaultDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+1162
-904
File diff suppressed because it is too large
Load Diff
+16
-16
@@ -2,28 +2,28 @@ import { describe, expect, it } from "vitest";
|
||||
import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js";
|
||||
|
||||
describe("normalizeE164", () => {
|
||||
it("strips whatsapp prefix and whitespace", () => {
|
||||
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
||||
});
|
||||
it("strips whatsapp prefix and whitespace", () => {
|
||||
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("adds plus when missing", () => {
|
||||
expect(normalizeE164("1555123")).toBe("+1555123");
|
||||
});
|
||||
it("adds plus when missing", () => {
|
||||
expect(normalizeE164("1555123")).toBe("+1555123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toWhatsappJid", () => {
|
||||
it("converts E164 to jid", () => {
|
||||
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
||||
});
|
||||
it("converts E164 to jid", () => {
|
||||
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertProvider", () => {
|
||||
it("accepts valid providers", () => {
|
||||
expect(() => assertProvider("twilio")).not.toThrow();
|
||||
expect(() => assertProvider("web")).not.toThrow();
|
||||
});
|
||||
it("accepts valid providers", () => {
|
||||
expect(() => assertProvider("twilio")).not.toThrow();
|
||||
expect(() => assertProvider("web")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on invalid provider", () => {
|
||||
expect(() => assertProvider("invalid" as string)).toThrow();
|
||||
});
|
||||
it("throws on invalid provider", () => {
|
||||
expect(() => assertProvider("invalid" as string)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+87
-69
@@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import {
|
||||
autoReplyIfConfigured,
|
||||
getReplyFromConfig,
|
||||
autoReplyIfConfigured,
|
||||
getReplyFromConfig,
|
||||
} from "./auto-reply/reply.js";
|
||||
import { applyTemplate } from "./auto-reply/templating.js";
|
||||
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
|
||||
@@ -13,42 +13,42 @@ import { promptYesNo } from "./cli/prompt.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { loadConfig } from "./config/config.js";
|
||||
import {
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "./config/sessions.js";
|
||||
import { readEnv } from "./env.js";
|
||||
import { ensureBinary } from "./infra/binaries.js";
|
||||
import {
|
||||
describePortOwner,
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
describePortOwner,
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
} from "./infra/ports.js";
|
||||
import {
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
} from "./infra/tailscale.js";
|
||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||
import { monitorWebProvider } from "./provider-web.js";
|
||||
import { createClient } from "./twilio/client.js";
|
||||
import {
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
sortByDateDesc,
|
||||
uniqueBySid,
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
sortByDateDesc,
|
||||
uniqueBySid,
|
||||
} from "./twilio/messages.js";
|
||||
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
||||
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
||||
import { sendTypingIndicator } from "./twilio/typing.js";
|
||||
import {
|
||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||
updateWebhook as updateWebhookImpl,
|
||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||
updateWebhook as updateWebhookImpl,
|
||||
} from "./twilio/update-webhook.js";
|
||||
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
||||
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
||||
@@ -66,56 +66,74 @@ const setMessagingServiceWebhook = setMessagingServiceWebhookImpl;
|
||||
const updateWebhook = updateWebhookImpl;
|
||||
|
||||
export {
|
||||
assertProvider,
|
||||
autoReplyIfConfigured,
|
||||
applyTemplate,
|
||||
createClient,
|
||||
deriveSessionKey,
|
||||
describePortOwner,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensurePortAvailable,
|
||||
ensureTailscaledInstalled,
|
||||
findIncomingNumberSidImpl as findIncomingNumberSid,
|
||||
findMessagingServiceSidImpl as findMessagingServiceSid,
|
||||
findWhatsappSenderSid,
|
||||
formatMessageLine,
|
||||
formatTwilioError,
|
||||
getReplyFromConfig,
|
||||
getTailnetHostname,
|
||||
handlePortError,
|
||||
logTwilioSendError,
|
||||
listRecentMessages,
|
||||
loadConfig,
|
||||
loadSessionStore,
|
||||
monitorTwilio,
|
||||
monitorWebProvider,
|
||||
normalizeE164,
|
||||
PortInUseError,
|
||||
promptYesNo,
|
||||
createDefaultDeps,
|
||||
readEnv,
|
||||
resolveStorePath,
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
saveSessionStore,
|
||||
sendMessage,
|
||||
sendTypingIndicator,
|
||||
setMessagingServiceWebhook,
|
||||
sortByDateDesc,
|
||||
startWebhook,
|
||||
updateWebhook,
|
||||
uniqueBySid,
|
||||
waitForFinalStatus,
|
||||
waitForever,
|
||||
toWhatsappJid,
|
||||
program,
|
||||
assertProvider,
|
||||
autoReplyIfConfigured,
|
||||
applyTemplate,
|
||||
createClient,
|
||||
deriveSessionKey,
|
||||
describePortOwner,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensurePortAvailable,
|
||||
ensureTailscaledInstalled,
|
||||
findIncomingNumberSidImpl as findIncomingNumberSid,
|
||||
findMessagingServiceSidImpl as findMessagingServiceSid,
|
||||
findWhatsappSenderSid,
|
||||
formatMessageLine,
|
||||
formatTwilioError,
|
||||
getReplyFromConfig,
|
||||
getTailnetHostname,
|
||||
handlePortError,
|
||||
logTwilioSendError,
|
||||
listRecentMessages,
|
||||
loadConfig,
|
||||
loadSessionStore,
|
||||
monitorTwilio,
|
||||
monitorWebProvider,
|
||||
normalizeE164,
|
||||
PortInUseError,
|
||||
promptYesNo,
|
||||
createDefaultDeps,
|
||||
readEnv,
|
||||
resolveStorePath,
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
saveSessionStore,
|
||||
sendMessage,
|
||||
sendTypingIndicator,
|
||||
setMessagingServiceWebhook,
|
||||
sortByDateDesc,
|
||||
startWebhook,
|
||||
updateWebhook,
|
||||
uniqueBySid,
|
||||
waitForFinalStatus,
|
||||
waitForever,
|
||||
toWhatsappJid,
|
||||
program,
|
||||
};
|
||||
|
||||
const isMain =
|
||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
|
||||
if (isMain) {
|
||||
program.parseAsync(process.argv);
|
||||
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
|
||||
// These log the error and exit gracefully instead of crashing without trace.
|
||||
process.on("unhandledRejection", (reason, _promise) => {
|
||||
console.error(
|
||||
"[warelay] Unhandled promise rejection:",
|
||||
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error(
|
||||
"[warelay] Uncaught exception:",
|
||||
error.stack ?? error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv);
|
||||
}
|
||||
|
||||
+29
-29
@@ -5,34 +5,34 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { ensureBinary } from "./binaries.js";
|
||||
|
||||
describe("ensureBinary", () => {
|
||||
it("passes through when binary exists", async () => {
|
||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
await ensureBinary("node", exec, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||
});
|
||||
it("passes through when binary exists", async () => {
|
||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
await ensureBinary("node", exec, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||
});
|
||||
|
||||
it("logs and exits when missing", async () => {
|
||||
const exec: typeof runExec = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("missing"));
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
});
|
||||
await expect(
|
||||
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"Missing required binary: ghost. Please install it.",
|
||||
);
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
it("logs and exits when missing", async () => {
|
||||
const exec: typeof runExec = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("missing"));
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
});
|
||||
await expect(
|
||||
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"Missing required binary: ghost. Please install it.",
|
||||
);
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@ import { runExec } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export async function ensureBinary(
|
||||
name: string,
|
||||
exec: typeof runExec = runExec,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
name: string,
|
||||
exec: typeof runExec = runExec,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<void> {
|
||||
// Abort early if a required CLI tool is missing.
|
||||
await exec("which", [name]).catch(() => {
|
||||
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
||||
runtime.exit(1);
|
||||
});
|
||||
// Abort early if a required CLI tool is missing.
|
||||
await exec("which", [name]).catch(() => {
|
||||
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
||||
runtime.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
+27
-27
@@ -2,35 +2,35 @@ import net from "node:net";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
} from "./ports.js";
|
||||
|
||||
describe("ports helpers", () => {
|
||||
it("ensurePortAvailable rejects when port busy", async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||
PortInUseError,
|
||||
);
|
||||
server.close();
|
||||
});
|
||||
it("ensurePortAvailable rejects when port busy", async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||
PortInUseError,
|
||||
);
|
||||
server.close();
|
||||
});
|
||||
|
||||
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: vi.fn() as unknown as (code: number) => never,
|
||||
};
|
||||
await handlePortError(
|
||||
{ code: "EADDRINUSE" },
|
||||
1234,
|
||||
"context",
|
||||
runtime,
|
||||
).catch(() => {});
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: vi.fn() as unknown as (code: number) => never,
|
||||
};
|
||||
await handlePortError(
|
||||
{ code: "EADDRINUSE" },
|
||||
1234,
|
||||
"context",
|
||||
runtime,
|
||||
).catch(() => {});
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
+82
-82
@@ -5,103 +5,103 @@ import { runExec } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
class PortInUseError extends Error {
|
||||
port: number;
|
||||
details?: string;
|
||||
port: number;
|
||||
details?: string;
|
||||
|
||||
constructor(port: number, details?: string) {
|
||||
super(`Port ${port} is already in use.`);
|
||||
this.name = "PortInUseError";
|
||||
this.port = port;
|
||||
this.details = details;
|
||||
}
|
||||
constructor(port: number, details?: string) {
|
||||
super(`Port ${port} is already in use.`);
|
||||
this.name = "PortInUseError";
|
||||
this.port = port;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
}
|
||||
|
||||
export async function describePortOwner(
|
||||
port: number,
|
||||
port: number,
|
||||
): Promise<string | undefined> {
|
||||
// Best-effort process info for a listening port (macOS/Linux).
|
||||
try {
|
||||
const { stdout } = await runExec("lsof", [
|
||||
"-i",
|
||||
`tcp:${port}`,
|
||||
"-sTCP:LISTEN",
|
||||
"-nP",
|
||||
]);
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed) return trimmed;
|
||||
} catch (err) {
|
||||
logVerbose(`lsof unavailable: ${String(err)}`);
|
||||
}
|
||||
return undefined;
|
||||
// Best-effort process info for a listening port (macOS/Linux).
|
||||
try {
|
||||
const { stdout } = await runExec("lsof", [
|
||||
"-i",
|
||||
`tcp:${port}`,
|
||||
"-sTCP:LISTEN",
|
||||
"-nP",
|
||||
]);
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed) return trimmed;
|
||||
} catch (err) {
|
||||
logVerbose(`lsof unavailable: ${String(err)}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function ensurePortAvailable(port: number): Promise<void> {
|
||||
// Detect EADDRINUSE early with a friendly message.
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once("error", (err) => reject(err))
|
||||
.once("listening", () => {
|
||||
tester.close(() => resolve());
|
||||
})
|
||||
.listen(port);
|
||||
});
|
||||
} catch (err) {
|
||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||
const details = await describePortOwner(port);
|
||||
throw new PortInUseError(port, details);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Detect EADDRINUSE early with a friendly message.
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once("error", (err) => reject(err))
|
||||
.once("listening", () => {
|
||||
tester.close(() => resolve());
|
||||
})
|
||||
.listen(port);
|
||||
});
|
||||
} catch (err) {
|
||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||
const details = await describePortOwner(port);
|
||||
throw new PortInUseError(port, details);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePortError(
|
||||
err: unknown,
|
||||
port: number,
|
||||
context: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
err: unknown,
|
||||
port: number,
|
||||
context: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<never> {
|
||||
// Uniform messaging for EADDRINUSE with optional owner details.
|
||||
if (
|
||||
err instanceof PortInUseError ||
|
||||
(isErrno(err) && err.code === "EADDRINUSE")
|
||||
) {
|
||||
const details =
|
||||
err instanceof PortInUseError
|
||||
? err.details
|
||||
: await describePortOwner(port);
|
||||
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
||||
if (details) {
|
||||
runtime.error(info("Port listener details:"));
|
||||
runtime.error(details);
|
||||
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
||||
runtime.error(
|
||||
warn(
|
||||
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
runtime.error(danger(`${context} failed: ${String(err)}`));
|
||||
if (isVerbose()) {
|
||||
const stdout = (err as { stdout?: string })?.stdout;
|
||||
const stderr = (err as { stderr?: string })?.stderr;
|
||||
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
||||
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
||||
}
|
||||
return runtime.exit(1);
|
||||
// Uniform messaging for EADDRINUSE with optional owner details.
|
||||
if (
|
||||
err instanceof PortInUseError ||
|
||||
(isErrno(err) && err.code === "EADDRINUSE")
|
||||
) {
|
||||
const details =
|
||||
err instanceof PortInUseError
|
||||
? err.details
|
||||
: await describePortOwner(port);
|
||||
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
||||
if (details) {
|
||||
runtime.error(info("Port listener details:"));
|
||||
runtime.error(details);
|
||||
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
||||
runtime.error(
|
||||
warn(
|
||||
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
runtime.error(danger(`${context} failed: ${String(err)}`));
|
||||
if (isVerbose()) {
|
||||
const stdout = (err as { stdout?: string })?.stdout;
|
||||
const stderr = (err as { stderr?: string })?.stderr;
|
||||
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
||||
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
||||
}
|
||||
return runtime.exit(1);
|
||||
}
|
||||
|
||||
export { PortInUseError };
|
||||
|
||||
+20
-20
@@ -3,26 +3,26 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { retryAsync } from "./retry.js";
|
||||
|
||||
describe("retryAsync", () => {
|
||||
it("returns on first success", async () => {
|
||||
const fn = vi.fn().mockResolvedValue("ok");
|
||||
const result = await retryAsync(fn, 3, 10);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("returns on first success", async () => {
|
||||
const fn = vi.fn().mockResolvedValue("ok");
|
||||
const result = await retryAsync(fn, 3, 10);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries then succeeds", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("fail1"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const result = await retryAsync(fn, 3, 1);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("retries then succeeds", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("fail1"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const result = await retryAsync(fn, 3, 1);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("propagates after exhausting retries", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("propagates after exhausting retries", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
+15
-15
@@ -1,18 +1,18 @@
|
||||
export async function retryAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
attempts = 3,
|
||||
initialDelayMs = 300,
|
||||
fn: () => Promise<T>,
|
||||
attempts = 3,
|
||||
initialDelayMs = 300,
|
||||
): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i === attempts - 1) break;
|
||||
const delay = initialDelayMs * 2 ** i;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i === attempts - 1) break;
|
||||
const delay = initialDelayMs * 2 ** i;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
+51
-51
@@ -1,61 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
} from "./tailscale.js";
|
||||
|
||||
describe("tailscale helpers", () => {
|
||||
it("parses DNS name from tailscale status", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
||||
}),
|
||||
});
|
||||
const host = await getTailnetHostname(exec);
|
||||
expect(host).toBe("host.tailnet.ts.net");
|
||||
});
|
||||
it("parses DNS name from tailscale status", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
||||
}),
|
||||
});
|
||||
const host = await getTailnetHostname(exec);
|
||||
expect(host).toBe("host.tailnet.ts.net");
|
||||
});
|
||||
|
||||
it("falls back to IP when DNS missing", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
||||
});
|
||||
const host = await getTailnetHostname(exec);
|
||||
expect(host).toBe("100.2.2.2");
|
||||
});
|
||||
it("falls back to IP when DNS missing", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
||||
});
|
||||
const host = await getTailnetHostname(exec);
|
||||
expect(host).toBe("100.2.2.2");
|
||||
});
|
||||
|
||||
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("no go"))
|
||||
.mockResolvedValue({}); // brew install go
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await ensureGoInstalled(exec as never, prompt, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||||
});
|
||||
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("no go"))
|
||||
.mockResolvedValue({}); // brew install go
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await ensureGoInstalled(exec as never, prompt, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||||
});
|
||||
|
||||
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("missing"))
|
||||
.mockResolvedValue({});
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||
});
|
||||
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("missing"))
|
||||
.mockResolvedValue({});
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||
});
|
||||
});
|
||||
|
||||
+140
-140
@@ -6,158 +6,158 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { ensureBinary } from "./binaries.js";
|
||||
|
||||
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
||||
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
||||
const self =
|
||||
typeof parsed.Self === "object" && parsed.Self !== null
|
||||
? (parsed.Self as Record<string, unknown>)
|
||||
: undefined;
|
||||
const dns =
|
||||
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
||||
const ips = Array.isArray(self?.TailscaleIPs)
|
||||
? (self.TailscaleIPs as string[])
|
||||
: [];
|
||||
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
||||
if (ips.length > 0) return ips[0];
|
||||
throw new Error("Could not determine Tailscale DNS or IP");
|
||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
||||
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
||||
const self =
|
||||
typeof parsed.Self === "object" && parsed.Self !== null
|
||||
? (parsed.Self as Record<string, unknown>)
|
||||
: undefined;
|
||||
const dns =
|
||||
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
||||
const ips = Array.isArray(self?.TailscaleIPs)
|
||||
? (self.TailscaleIPs as string[])
|
||||
: [];
|
||||
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
||||
if (ips.length > 0) return ips[0];
|
||||
throw new Error("Could not determine Tailscale DNS or IP");
|
||||
}
|
||||
|
||||
export async function ensureGoInstalled(
|
||||
exec: typeof runExec = runExec,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
exec: typeof runExec = runExec,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
||||
const hasGo = await exec("go", ["version"]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (hasGo) return;
|
||||
const install = await prompt(
|
||||
"Go is not installed. Install via Homebrew (brew install go)?",
|
||||
true,
|
||||
);
|
||||
if (!install) {
|
||||
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose("Installing Go via Homebrew…");
|
||||
await exec("brew", ["install", "go"]);
|
||||
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
||||
const hasGo = await exec("go", ["version"]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (hasGo) return;
|
||||
const install = await prompt(
|
||||
"Go is not installed. Install via Homebrew (brew install go)?",
|
||||
true,
|
||||
);
|
||||
if (!install) {
|
||||
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose("Installing Go via Homebrew…");
|
||||
await exec("brew", ["install", "go"]);
|
||||
}
|
||||
|
||||
export async function ensureTailscaledInstalled(
|
||||
exec: typeof runExec = runExec,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
exec: typeof runExec = runExec,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
||||
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (hasTailscaled) return;
|
||||
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
||||
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (hasTailscaled) return;
|
||||
|
||||
const install = await prompt(
|
||||
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
||||
true,
|
||||
);
|
||||
if (!install) {
|
||||
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose("Installing tailscaled via Homebrew…");
|
||||
await exec("brew", ["install", "tailscale"]);
|
||||
const install = await prompt(
|
||||
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
||||
true,
|
||||
);
|
||||
if (!install) {
|
||||
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose("Installing tailscaled via Homebrew…");
|
||||
await exec("brew", ["install", "tailscale"]);
|
||||
}
|
||||
|
||||
export async function ensureFunnel(
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
) {
|
||||
// Ensure Funnel is enabled and publish the webhook port.
|
||||
try {
|
||||
const statusOut = (
|
||||
await exec("tailscale", ["funnel", "status", "--json"])
|
||||
).stdout.trim();
|
||||
const parsed = statusOut
|
||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||
: {};
|
||||
if (!parsed || Object.keys(parsed).length === 0) {
|
||||
runtime.error(
|
||||
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
),
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
||||
),
|
||||
);
|
||||
const proceed = await prompt(
|
||||
"Attempt local setup with user-space tailscaled?",
|
||||
true,
|
||||
);
|
||||
if (!proceed) runtime.exit(1);
|
||||
await ensureBinary("brew", exec, runtime);
|
||||
await ensureGoInstalled(exec, prompt, runtime);
|
||||
await ensureTailscaledInstalled(exec, prompt, runtime);
|
||||
}
|
||||
// Ensure Funnel is enabled and publish the webhook port.
|
||||
try {
|
||||
const statusOut = (
|
||||
await exec("tailscale", ["funnel", "status", "--json"])
|
||||
).stdout.trim();
|
||||
const parsed = statusOut
|
||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||
: {};
|
||||
if (!parsed || Object.keys(parsed).length === 0) {
|
||||
runtime.error(
|
||||
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
),
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
||||
),
|
||||
);
|
||||
const proceed = await prompt(
|
||||
"Attempt local setup with user-space tailscaled?",
|
||||
true,
|
||||
);
|
||||
if (!proceed) runtime.exit(1);
|
||||
await ensureBinary("brew", exec, runtime);
|
||||
await ensureGoInstalled(exec, prompt, runtime);
|
||||
await ensureTailscaledInstalled(exec, prompt, runtime);
|
||||
}
|
||||
|
||||
logVerbose(`Enabling funnel on port ${port}…`);
|
||||
const { stdout } = await exec(
|
||||
"tailscale",
|
||||
["funnel", "--yes", "--bg", `${port}`],
|
||||
{
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
);
|
||||
if (stdout.trim()) console.log(stdout.trim());
|
||||
} catch (err) {
|
||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
||||
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
||||
if (stdout.includes("Funnel is not enabled")) {
|
||||
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
||||
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
||||
if (linkMatch) {
|
||||
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
||||
} else {
|
||||
console.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
stderr.includes("client version") ||
|
||||
stdout.includes("client version")
|
||||
) {
|
||||
console.error(
|
||||
warn(
|
||||
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.error(
|
||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||
),
|
||||
);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
||||
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose(`Enabling funnel on port ${port}…`);
|
||||
const { stdout } = await exec(
|
||||
"tailscale",
|
||||
["funnel", "--yes", "--bg", `${port}`],
|
||||
{
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
);
|
||||
if (stdout.trim()) console.log(stdout.trim());
|
||||
} catch (err) {
|
||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
||||
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
||||
if (stdout.includes("Funnel is not enabled")) {
|
||||
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
||||
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
||||
if (linkMatch) {
|
||||
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
||||
} else {
|
||||
console.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
stderr.includes("client version") ||
|
||||
stdout.includes("client version")
|
||||
) {
|
||||
console.error(
|
||||
warn(
|
||||
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.error(
|
||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||
),
|
||||
);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
||||
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+55
-55
@@ -11,72 +11,72 @@ import { resetLogger, setLoggerOverride } from "./logging.js";
|
||||
import type { RuntimeEnv } from "./runtime.js";
|
||||
|
||||
describe("logger helpers", () => {
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
setVerbose(false);
|
||||
});
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
setVerbose(false);
|
||||
});
|
||||
|
||||
it("formats messages through runtime log/error", () => {
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
||||
it("formats messages through runtime log/error", () => {
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
||||
|
||||
logInfo("info", runtime);
|
||||
logWarn("warn", runtime);
|
||||
logSuccess("ok", runtime);
|
||||
logError("bad", runtime);
|
||||
logInfo("info", runtime);
|
||||
logWarn("warn", runtime);
|
||||
logSuccess("ok", runtime);
|
||||
logError("bad", runtime);
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(3);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(log).toHaveBeenCalledTimes(3);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("only logs debug when verbose is enabled", () => {
|
||||
const logVerbose = vi.spyOn(console, "log");
|
||||
setVerbose(false);
|
||||
logDebug("quiet");
|
||||
expect(logVerbose).not.toHaveBeenCalled();
|
||||
it("only logs debug when verbose is enabled", () => {
|
||||
const logVerbose = vi.spyOn(console, "log");
|
||||
setVerbose(false);
|
||||
logDebug("quiet");
|
||||
expect(logVerbose).not.toHaveBeenCalled();
|
||||
|
||||
setVerbose(true);
|
||||
logVerbose.mockClear();
|
||||
logDebug("loud");
|
||||
expect(logVerbose).toHaveBeenCalled();
|
||||
logVerbose.mockRestore();
|
||||
});
|
||||
setVerbose(true);
|
||||
logVerbose.mockClear();
|
||||
logDebug("loud");
|
||||
expect(logVerbose).toHaveBeenCalled();
|
||||
logVerbose.mockRestore();
|
||||
});
|
||||
|
||||
it("writes to configured log file at configured level", () => {
|
||||
const logPath = pathForTest();
|
||||
cleanup(logPath);
|
||||
setLoggerOverride({ level: "debug", file: logPath });
|
||||
logInfo("hello");
|
||||
logDebug("debug-only");
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
expect(content).toContain("hello");
|
||||
expect(content).toContain("debug-only");
|
||||
cleanup(logPath);
|
||||
});
|
||||
it("writes to configured log file at configured level", () => {
|
||||
const logPath = pathForTest();
|
||||
cleanup(logPath);
|
||||
setLoggerOverride({ level: "debug", file: logPath });
|
||||
logInfo("hello");
|
||||
logDebug("debug-only");
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
expect(content).toContain("hello");
|
||||
expect(content).toContain("debug-only");
|
||||
cleanup(logPath);
|
||||
});
|
||||
|
||||
it("filters messages below configured level", () => {
|
||||
const logPath = pathForTest();
|
||||
cleanup(logPath);
|
||||
setLoggerOverride({ level: "warn", file: logPath });
|
||||
logInfo("info-only");
|
||||
logWarn("warn-only");
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
expect(content).not.toContain("info-only");
|
||||
expect(content).toContain("warn-only");
|
||||
cleanup(logPath);
|
||||
});
|
||||
it("filters messages below configured level", () => {
|
||||
const logPath = pathForTest();
|
||||
cleanup(logPath);
|
||||
setLoggerOverride({ level: "warn", file: logPath });
|
||||
logInfo("info-only");
|
||||
logWarn("warn-only");
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
expect(content).not.toContain("info-only");
|
||||
expect(content).toContain("warn-only");
|
||||
cleanup(logPath);
|
||||
});
|
||||
});
|
||||
|
||||
function pathForTest() {
|
||||
return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`);
|
||||
return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`);
|
||||
}
|
||||
|
||||
function cleanup(file: string) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
+21
-21
@@ -1,42 +1,42 @@
|
||||
import {
|
||||
danger,
|
||||
info,
|
||||
isVerbose,
|
||||
logVerbose,
|
||||
success,
|
||||
warn,
|
||||
danger,
|
||||
info,
|
||||
isVerbose,
|
||||
logVerbose,
|
||||
success,
|
||||
warn,
|
||||
} from "./globals.js";
|
||||
import { getLogger } from "./logging.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||
|
||||
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||
runtime.log(info(message));
|
||||
getLogger().info(message);
|
||||
runtime.log(info(message));
|
||||
getLogger().info(message);
|
||||
}
|
||||
|
||||
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||
runtime.log(warn(message));
|
||||
getLogger().warn(message);
|
||||
runtime.log(warn(message));
|
||||
getLogger().warn(message);
|
||||
}
|
||||
|
||||
export function logSuccess(
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
runtime.log(success(message));
|
||||
getLogger().info(message);
|
||||
runtime.log(success(message));
|
||||
getLogger().info(message);
|
||||
}
|
||||
|
||||
export function logError(
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
runtime.error(danger(message));
|
||||
getLogger().error(message);
|
||||
runtime.error(danger(message));
|
||||
getLogger().error(message);
|
||||
}
|
||||
|
||||
export function logDebug(message: string) {
|
||||
// Always emit to file logger (level-filtered); console only when verbose.
|
||||
getLogger().debug(message);
|
||||
if (isVerbose()) logVerbose(message);
|
||||
// Always emit to file logger (level-filtered); console only when verbose.
|
||||
getLogger().debug(message);
|
||||
if (isVerbose()) logVerbose(message);
|
||||
}
|
||||
|
||||
+57
-52
@@ -10,92 +10,97 @@ const DEFAULT_LOG_DIR = path.join(os.tmpdir(), "warelay");
|
||||
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log");
|
||||
|
||||
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
||||
"silent",
|
||||
"fatal",
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug",
|
||||
"trace",
|
||||
"silent",
|
||||
"fatal",
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug",
|
||||
"trace",
|
||||
];
|
||||
|
||||
export type LoggerSettings = {
|
||||
level?: LevelWithSilent;
|
||||
file?: string;
|
||||
level?: LevelWithSilent;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
type ResolvedSettings = {
|
||||
level: LevelWithSilent;
|
||||
file: string;
|
||||
level: LevelWithSilent;
|
||||
file: string;
|
||||
};
|
||||
export type LoggerResolvedSettings = ResolvedSettings;
|
||||
|
||||
let cachedLogger: Logger | null = null;
|
||||
let cachedSettings: ResolvedSettings | null = null;
|
||||
let overrideSettings: LoggerSettings | null = null;
|
||||
|
||||
function normalizeLevel(level?: string): LevelWithSilent {
|
||||
if (isVerbose()) return "debug";
|
||||
const candidate = level ?? "info";
|
||||
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
||||
? (candidate as LevelWithSilent)
|
||||
: "info";
|
||||
if (isVerbose()) return "debug";
|
||||
const candidate = level ?? "info";
|
||||
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
||||
? (candidate as LevelWithSilent)
|
||||
: "info";
|
||||
}
|
||||
|
||||
function resolveSettings(): ResolvedSettings {
|
||||
const cfg: WarelayConfig["logging"] | undefined =
|
||||
overrideSettings ?? loadConfig().logging;
|
||||
const level = normalizeLevel(cfg?.level);
|
||||
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
||||
return { level, file };
|
||||
const cfg: WarelayConfig["logging"] | undefined =
|
||||
overrideSettings ?? loadConfig().logging;
|
||||
const level = normalizeLevel(cfg?.level);
|
||||
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
||||
return { level, file };
|
||||
}
|
||||
|
||||
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
||||
if (!a) return true;
|
||||
return a.level !== b.level || a.file !== b.file;
|
||||
if (!a) return true;
|
||||
return a.level !== b.level || a.file !== b.file;
|
||||
}
|
||||
|
||||
function buildLogger(settings: ResolvedSettings): Logger {
|
||||
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
||||
const destination = pino.destination({
|
||||
dest: settings.file,
|
||||
mkdir: true,
|
||||
sync: true, // deterministic for tests; log volume is modest.
|
||||
});
|
||||
return pino(
|
||||
{
|
||||
level: settings.level,
|
||||
base: undefined,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
},
|
||||
destination,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
||||
const destination = pino.destination({
|
||||
dest: settings.file,
|
||||
mkdir: true,
|
||||
sync: true, // deterministic for tests; log volume is modest.
|
||||
});
|
||||
return pino(
|
||||
{
|
||||
level: settings.level,
|
||||
base: undefined,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
},
|
||||
destination,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLogger(): Logger {
|
||||
const settings = resolveSettings();
|
||||
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
||||
cachedLogger = buildLogger(settings);
|
||||
cachedSettings = settings;
|
||||
}
|
||||
return cachedLogger;
|
||||
const settings = resolveSettings();
|
||||
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
||||
cachedLogger = buildLogger(settings);
|
||||
cachedSettings = settings;
|
||||
}
|
||||
return cachedLogger;
|
||||
}
|
||||
|
||||
export function getChildLogger(
|
||||
bindings?: Bindings,
|
||||
opts?: { level?: LevelWithSilent },
|
||||
bindings?: Bindings,
|
||||
opts?: { level?: LevelWithSilent },
|
||||
): Logger {
|
||||
return getLogger().child(bindings ?? {}, opts);
|
||||
return getLogger().child(bindings ?? {}, opts);
|
||||
}
|
||||
|
||||
export function getResolvedLoggerSettings(): LoggerResolvedSettings {
|
||||
return resolveSettings();
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
export function setLoggerOverride(settings: LoggerSettings | null) {
|
||||
overrideSettings = settings;
|
||||
cachedLogger = null;
|
||||
cachedSettings = null;
|
||||
overrideSettings = settings;
|
||||
cachedLogger = null;
|
||||
cachedSettings = null;
|
||||
}
|
||||
|
||||
export function resetLogger() {
|
||||
cachedLogger = null;
|
||||
cachedSettings = null;
|
||||
overrideSettings = null;
|
||||
cachedLogger = null;
|
||||
cachedSettings = null;
|
||||
overrideSettings = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export const MAX_IMAGE_BYTES = 6 * 1024 * 1024; // 6MB
|
||||
export const MAX_AUDIO_BYTES = 16 * 1024 * 1024; // 16MB
|
||||
export const MAX_VIDEO_BYTES = 16 * 1024 * 1024; // 16MB
|
||||
export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||
|
||||
export function mediaKindFromMime(mime?: string | null): MediaKind {
|
||||
if (!mime) return "unknown";
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime === "application/pdf") return "document";
|
||||
if (mime.startsWith("application/")) return "document";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function maxBytesForKind(kind: MediaKind): number {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return MAX_IMAGE_BYTES;
|
||||
case "audio":
|
||||
return MAX_AUDIO_BYTES;
|
||||
case "video":
|
||||
return MAX_VIDEO_BYTES;
|
||||
case "document":
|
||||
return MAX_DOCUMENT_BYTES;
|
||||
default:
|
||||
return MAX_DOCUMENT_BYTES;
|
||||
}
|
||||
}
|
||||
+64
-64
@@ -12,11 +12,11 @@ const logInfo = vi.fn();
|
||||
vi.mock("./store.js", () => ({ saveMediaSource }));
|
||||
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
||||
vi.mock("../infra/ports.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../infra/ports.js")>(
|
||||
"../infra/ports.js",
|
||||
);
|
||||
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../infra/ports.js")>(
|
||||
"../infra/ports.js",
|
||||
);
|
||||
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
||||
});
|
||||
vi.mock("./server.js", () => ({ startMediaServer }));
|
||||
vi.mock("../logger.js", () => ({ logInfo }));
|
||||
@@ -25,69 +25,69 @@ const { ensureMediaHosted } = await import("./host.js");
|
||||
const { PortInUseError } = await import("../infra/ports.js");
|
||||
|
||||
describe("ensureMediaHosted", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("throws and cleans up when server not allowed to start", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id1",
|
||||
path: "/tmp/file1",
|
||||
size: 5,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tailnet-host");
|
||||
ensurePortAvailable.mockResolvedValue(undefined);
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
it("throws and cleans up when server not allowed to start", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id1",
|
||||
path: "/tmp/file1",
|
||||
size: 5,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tailnet-host");
|
||||
ensurePortAvailable.mockResolvedValue(undefined);
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
||||
).rejects.toThrow("requires the webhook/Funnel server");
|
||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
||||
rmSpy.mockRestore();
|
||||
});
|
||||
await expect(
|
||||
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
||||
).rejects.toThrow("requires the webhook/Funnel server");
|
||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
||||
rmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("starts media server when allowed", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id2",
|
||||
path: "/tmp/file2",
|
||||
size: 9,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tail.net");
|
||||
ensurePortAvailable.mockResolvedValue(undefined);
|
||||
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
||||
startMediaServer.mockResolvedValue(fakeServer);
|
||||
it("starts media server when allowed", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id2",
|
||||
path: "/tmp/file2",
|
||||
size: 9,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tail.net");
|
||||
ensurePortAvailable.mockResolvedValue(undefined);
|
||||
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
||||
startMediaServer.mockResolvedValue(fakeServer);
|
||||
|
||||
const result = await ensureMediaHosted("/tmp/file2", {
|
||||
startServer: true,
|
||||
port: 1234,
|
||||
});
|
||||
expect(startMediaServer).toHaveBeenCalledWith(
|
||||
1234,
|
||||
expect.any(Number),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(logInfo).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
url: "https://tail.net/media/id2",
|
||||
id: "id2",
|
||||
size: 9,
|
||||
});
|
||||
});
|
||||
const result = await ensureMediaHosted("/tmp/file2", {
|
||||
startServer: true,
|
||||
port: 1234,
|
||||
});
|
||||
expect(startMediaServer).toHaveBeenCalledWith(
|
||||
1234,
|
||||
expect.any(Number),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(logInfo).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
url: "https://tail.net/media/id2",
|
||||
id: "id2",
|
||||
size: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips server start when port already in use", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id3",
|
||||
path: "/tmp/file3",
|
||||
size: 7,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tail.net");
|
||||
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
||||
it("skips server start when port already in use", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id3",
|
||||
path: "/tmp/file3",
|
||||
size: 7,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tail.net");
|
||||
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
||||
|
||||
const result = await ensureMediaHosted("/tmp/file3", {
|
||||
startServer: false,
|
||||
port: 3000,
|
||||
});
|
||||
expect(startMediaServer).not.toHaveBeenCalled();
|
||||
expect(result.url).toBe("https://tail.net/media/id3");
|
||||
});
|
||||
const result = await ensureMediaHosted("/tmp/file3", {
|
||||
startServer: false,
|
||||
port: 3000,
|
||||
});
|
||||
expect(startMediaServer).not.toHaveBeenCalled();
|
||||
expect(result.url).toBe("https://tail.net/media/id3");
|
||||
});
|
||||
});
|
||||
|
||||
+40
-40
@@ -12,54 +12,54 @@ const TTL_MS = 2 * 60 * 1000;
|
||||
let mediaServer: import("http").Server | null = null;
|
||||
|
||||
export type HostedMedia = {
|
||||
url: string;
|
||||
id: string;
|
||||
size: number;
|
||||
url: string;
|
||||
id: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export async function ensureMediaHosted(
|
||||
source: string,
|
||||
opts: {
|
||||
port?: number;
|
||||
startServer?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
} = {},
|
||||
source: string,
|
||||
opts: {
|
||||
port?: number;
|
||||
startServer?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
} = {},
|
||||
): Promise<HostedMedia> {
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
|
||||
const saved = await saveMediaSource(source);
|
||||
const hostname = await getTailnetHostname();
|
||||
const saved = await saveMediaSource(source);
|
||||
const hostname = await getTailnetHostname();
|
||||
|
||||
// Decide whether we must start a media server.
|
||||
const needsServerStart = await isPortFree(port);
|
||||
if (needsServerStart && !opts.startServer) {
|
||||
await fs.rm(saved.path).catch(() => {});
|
||||
throw new Error(
|
||||
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
||||
);
|
||||
}
|
||||
if (needsServerStart && opts.startServer) {
|
||||
if (!mediaServer) {
|
||||
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
||||
logInfo(
|
||||
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
||||
runtime,
|
||||
);
|
||||
mediaServer.unref?.();
|
||||
}
|
||||
}
|
||||
// Decide whether we must start a media server.
|
||||
const needsServerStart = await isPortFree(port);
|
||||
if (needsServerStart && !opts.startServer) {
|
||||
await fs.rm(saved.path).catch(() => {});
|
||||
throw new Error(
|
||||
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
||||
);
|
||||
}
|
||||
if (needsServerStart && opts.startServer) {
|
||||
if (!mediaServer) {
|
||||
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
||||
logInfo(
|
||||
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
||||
runtime,
|
||||
);
|
||||
mediaServer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
const url = `https://${hostname}/media/${saved.id}`;
|
||||
return { url, id: saved.id, size: saved.size };
|
||||
const url = `https://${hostname}/media/${saved.id}`;
|
||||
return { url, id: saved.id, size: saved.size };
|
||||
}
|
||||
|
||||
async function isPortFree(port: number) {
|
||||
try {
|
||||
await ensurePortAvailable(port);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof PortInUseError) return false;
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await ensurePortAvailable(port);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof PortInUseError) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
+82
-34
@@ -1,53 +1,101 @@
|
||||
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
||||
|
||||
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\s`]+)`?/i;
|
||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
|
||||
|
||||
export function normalizeMediaSource(src: string) {
|
||||
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
||||
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
||||
}
|
||||
|
||||
function cleanCandidate(raw: string) {
|
||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||
}
|
||||
|
||||
function isValidMedia(candidate: string) {
|
||||
if (!candidate) return false;
|
||||
if (candidate.length > 1024) return false;
|
||||
if (/\s/.test(candidate)) return false;
|
||||
return (
|
||||
/^https?:\/\//i.test(candidate) ||
|
||||
candidate.startsWith("/") ||
|
||||
candidate.startsWith("./")
|
||||
);
|
||||
if (!candidate) return false;
|
||||
if (candidate.length > 1024) return false;
|
||||
if (/\s/.test(candidate)) return false;
|
||||
return (
|
||||
/^https?:\/\//i.test(candidate) ||
|
||||
candidate.startsWith("/") ||
|
||||
candidate.startsWith("./")
|
||||
);
|
||||
}
|
||||
|
||||
export function splitMediaFromOutput(raw: string): {
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string; // legacy first item for backward compatibility
|
||||
} {
|
||||
const trimmedRaw = raw.trim();
|
||||
const match = MEDIA_TOKEN_RE.exec(trimmedRaw);
|
||||
if (!match?.[1]) return { text: trimmedRaw };
|
||||
const trimmedRaw = raw.trim();
|
||||
if (!trimmedRaw) return { text: "" };
|
||||
|
||||
const candidate = normalizeMediaSource(cleanCandidate(match[1]));
|
||||
const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
|
||||
const media: string[] = [];
|
||||
let foundMediaToken = false;
|
||||
|
||||
const cleanedText = mediaUrl
|
||||
? trimmedRaw
|
||||
.replace(match[0], "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim()
|
||||
: trimmedRaw
|
||||
.split("\n")
|
||||
.filter((line) => !MEDIA_TOKEN_RE.test(line))
|
||||
.join("\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
// Collect tokens line by line so we can strip them cleanly.
|
||||
const lines = trimmedRaw.split("\n");
|
||||
const keptLines: string[] = [];
|
||||
|
||||
return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText };
|
||||
for (const line of lines) {
|
||||
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
|
||||
if (matches.length === 0) {
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
foundMediaToken = true;
|
||||
const pieces: string[] = [];
|
||||
let cursor = 0;
|
||||
let hasValidMedia = false;
|
||||
|
||||
for (const match of matches) {
|
||||
const start = match.index ?? 0;
|
||||
pieces.push(line.slice(cursor, start));
|
||||
|
||||
const payload = match[1];
|
||||
const parts = payload.split(/\s+/).filter(Boolean);
|
||||
const invalidParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
const candidate = normalizeMediaSource(cleanCandidate(part));
|
||||
if (isValidMedia(candidate)) {
|
||||
media.push(candidate);
|
||||
hasValidMedia = true;
|
||||
} else {
|
||||
invalidParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidMedia && invalidParts.length > 0) {
|
||||
pieces.push(invalidParts.join(" "));
|
||||
}
|
||||
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
|
||||
pieces.push(line.slice(cursor));
|
||||
|
||||
const cleanedLine = pieces
|
||||
.join("")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
// If the line becomes empty, drop it.
|
||||
if (cleanedLine) {
|
||||
keptLines.push(cleanedLine);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanedText = keptLines
|
||||
.join("\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
|
||||
if (media.length === 0) {
|
||||
return { text: foundMediaToken ? cleanedText : trimmedRaw };
|
||||
}
|
||||
|
||||
return { text: cleanedText, mediaUrls: media, mediaUrl: media[0] };
|
||||
}
|
||||
|
||||
+33
-33
@@ -8,45 +8,45 @@ const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
|
||||
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("./store.js", () => ({
|
||||
getMediaDir: () => MEDIA_DIR,
|
||||
cleanOldMedia,
|
||||
getMediaDir: () => MEDIA_DIR,
|
||||
cleanOldMedia,
|
||||
}));
|
||||
|
||||
const { startMediaServer } = await import("./server.js");
|
||||
|
||||
describe("media server", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
});
|
||||
beforeAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("serves media and cleans up after send", async () => {
|
||||
const file = path.join(MEDIA_DIR, "file1");
|
||||
await fs.writeFile(file, "hello");
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/file1`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("hello");
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
it("serves media and cleans up after send", async () => {
|
||||
const file = path.join(MEDIA_DIR, "file1");
|
||||
await fs.writeFile(file, "hello");
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/file1`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("hello");
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("expires old media", async () => {
|
||||
const file = path.join(MEDIA_DIR, "old");
|
||||
await fs.writeFile(file, "stale");
|
||||
const past = Date.now() - 10_000;
|
||||
await fs.utimes(file, past / 1000, past / 1000);
|
||||
const server = await startMediaServer(0, 1_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/old`);
|
||||
expect(res.status).toBe(410);
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
it("expires old media", async () => {
|
||||
const file = path.join(MEDIA_DIR, "old");
|
||||
await fs.writeFile(file, "stale");
|
||||
const past = Date.now() - 10_000;
|
||||
await fs.utimes(file, past / 1000, past / 1000);
|
||||
const server = await startMediaServer(0, 1_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/old`);
|
||||
expect(res.status).toBe(410);
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
});
|
||||
|
||||
+42
-42
@@ -9,53 +9,53 @@ import { cleanOldMedia, getMediaDir } from "./store.js";
|
||||
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
export function attachMediaRoutes(
|
||||
app: Express,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
app: Express,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const mediaDir = getMediaDir();
|
||||
const mediaDir = getMediaDir();
|
||||
|
||||
app.get("/media/:id", async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const file = path.join(mediaDir, id);
|
||||
try {
|
||||
const stat = await fs.stat(file);
|
||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(file).catch(() => {});
|
||||
res.status(410).send("expired");
|
||||
return;
|
||||
}
|
||||
res.sendFile(file);
|
||||
// best-effort single-use cleanup after response ends
|
||||
res.on("finish", () => {
|
||||
setTimeout(() => {
|
||||
fs.rm(file).catch(() => {});
|
||||
}, 500);
|
||||
});
|
||||
} catch {
|
||||
res.status(404).send("not found");
|
||||
}
|
||||
});
|
||||
app.get("/media/:id", async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const file = path.join(mediaDir, id);
|
||||
try {
|
||||
const stat = await fs.stat(file);
|
||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(file).catch(() => {});
|
||||
res.status(410).send("expired");
|
||||
return;
|
||||
}
|
||||
res.sendFile(file);
|
||||
// best-effort single-use cleanup after response ends
|
||||
res.on("finish", () => {
|
||||
setTimeout(() => {
|
||||
fs.rm(file).catch(() => {});
|
||||
}, 500);
|
||||
});
|
||||
} catch {
|
||||
res.status(404).send("not found");
|
||||
}
|
||||
});
|
||||
|
||||
// periodic cleanup
|
||||
setInterval(() => {
|
||||
void cleanOldMedia(ttlMs);
|
||||
}, ttlMs).unref();
|
||||
// periodic cleanup
|
||||
setInterval(() => {
|
||||
void cleanOldMedia(ttlMs);
|
||||
}, ttlMs).unref();
|
||||
}
|
||||
|
||||
export async function startMediaServer(
|
||||
port: number,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
port: number,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<Server> {
|
||||
const app = express();
|
||||
attachMediaRoutes(app, ttlMs, runtime);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
server.once("listening", () => resolve(server));
|
||||
server.once("error", (err) => {
|
||||
runtime.error(danger(`Media server failed: ${String(err)}`));
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
const app = express();
|
||||
attachMediaRoutes(app, ttlMs, runtime);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
server.once("listening", () => resolve(server));
|
||||
server.once("error", (err) => {
|
||||
runtime.error(danger(`Media server failed: ${String(err)}`));
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+39
-39
@@ -7,54 +7,54 @@ const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
||||
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
default: { homedir: () => HOME },
|
||||
homedir: () => HOME,
|
||||
default: { homedir: () => HOME },
|
||||
homedir: () => HOME,
|
||||
}));
|
||||
|
||||
const store = await import("./store.js");
|
||||
|
||||
describe("media store", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
beforeAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates and returns media directory", async () => {
|
||||
const dir = await store.ensureMediaDir();
|
||||
expect(dir).toContain("warelay-home-test");
|
||||
const stat = await fs.stat(dir);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
it("creates and returns media directory", async () => {
|
||||
const dir = await store.ensureMediaDir();
|
||||
expect(dir).toContain("warelay-home-test");
|
||||
const stat = await fs.stat(dir);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("saves buffers and enforces size limit", async () => {
|
||||
const buf = Buffer.from("hello");
|
||||
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
||||
const savedStat = await fs.stat(saved.path);
|
||||
expect(savedStat.size).toBe(buf.length);
|
||||
expect(saved.contentType).toBe("text/plain");
|
||||
it("saves buffers and enforces size limit", async () => {
|
||||
const buf = Buffer.from("hello");
|
||||
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
||||
const savedStat = await fs.stat(saved.path);
|
||||
expect(savedStat.size).toBe(buf.length);
|
||||
expect(saved.contentType).toBe("text/plain");
|
||||
|
||||
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
||||
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||
"Media exceeds 5MB limit",
|
||||
);
|
||||
});
|
||||
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
||||
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||
"Media exceeds 5MB limit",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies local files and cleans old media", async () => {
|
||||
const srcFile = path.join(HOME, "tmp-src.txt");
|
||||
await fs.mkdir(HOME, { recursive: true });
|
||||
await fs.writeFile(srcFile, "local file");
|
||||
const saved = await store.saveMediaSource(srcFile);
|
||||
expect(saved.size).toBe(10);
|
||||
const savedStat = await fs.stat(saved.path);
|
||||
expect(savedStat.isFile()).toBe(true);
|
||||
it("copies local files and cleans old media", async () => {
|
||||
const srcFile = path.join(HOME, "tmp-src.txt");
|
||||
await fs.mkdir(HOME, { recursive: true });
|
||||
await fs.writeFile(srcFile, "local file");
|
||||
const saved = await store.saveMediaSource(srcFile);
|
||||
expect(saved.size).toBe(10);
|
||||
const savedStat = await fs.stat(saved.path);
|
||||
expect(savedStat.isFile()).toBe(true);
|
||||
|
||||
// make the file look old and ensure cleanOldMedia removes it
|
||||
const past = Date.now() - 10_000;
|
||||
await fs.utimes(saved.path, past / 1000, past / 1000);
|
||||
await store.cleanOldMedia(1);
|
||||
await expect(fs.stat(saved.path)).rejects.toThrow();
|
||||
});
|
||||
// make the file look old and ensure cleanOldMedia removes it
|
||||
const past = Date.now() - 10_000;
|
||||
await fs.utimes(saved.path, past / 1000, past / 1000);
|
||||
await store.cleanOldMedia(1);
|
||||
await expect(fs.stat(saved.path)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+80
-80
@@ -11,108 +11,108 @@ const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
export function getMediaDir() {
|
||||
return MEDIA_DIR;
|
||||
return MEDIA_DIR;
|
||||
}
|
||||
|
||||
export async function ensureMediaDir() {
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
return MEDIA_DIR;
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
return MEDIA_DIR;
|
||||
}
|
||||
|
||||
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
||||
await ensureMediaDir();
|
||||
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
entries.map(async (file) => {
|
||||
const full = path.join(MEDIA_DIR, file);
|
||||
const stat = await fs.stat(full).catch(() => null);
|
||||
if (!stat) return;
|
||||
if (now - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(full).catch(() => {});
|
||||
}
|
||||
}),
|
||||
);
|
||||
await ensureMediaDir();
|
||||
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
entries.map(async (file) => {
|
||||
const full = path.join(MEDIA_DIR, file);
|
||||
const stat = await fs.stat(full).catch(() => null);
|
||||
if (!stat) return;
|
||||
if (now - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(full).catch(() => {});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeUrl(src: string) {
|
||||
return /^https?:\/\//i.test(src);
|
||||
return /^https?:\/\//i.test(src);
|
||||
}
|
||||
|
||||
async function downloadToFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
headers?: Record<string, string>,
|
||||
url: string,
|
||||
dest: string,
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = request(url, { headers }, (res) => {
|
||||
if (!res.statusCode || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
const out = createWriteStream(dest);
|
||||
res.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_BYTES) {
|
||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||
}
|
||||
});
|
||||
pipeline(res, out)
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = request(url, { headers }, (res) => {
|
||||
if (!res.statusCode || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
const out = createWriteStream(dest);
|
||||
res.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_BYTES) {
|
||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||
}
|
||||
});
|
||||
pipeline(res, out)
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export type SavedMedia = {
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export async function saveMediaSource(
|
||||
source: string,
|
||||
headers?: Record<string, string>,
|
||||
subdir = "",
|
||||
source: string,
|
||||
headers?: Record<string, string>,
|
||||
subdir = "",
|
||||
): Promise<SavedMedia> {
|
||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await cleanOldMedia();
|
||||
const id = crypto.randomUUID();
|
||||
const dest = path.join(dir, id);
|
||||
if (looksLikeUrl(source)) {
|
||||
await downloadToFile(source, dest, headers);
|
||||
const stat = await fs.stat(dest);
|
||||
return { id, path: dest, size: stat.size };
|
||||
}
|
||||
// local path
|
||||
const stat = await fs.stat(source);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Media path is not a file");
|
||||
}
|
||||
if (stat.size > MAX_BYTES) {
|
||||
throw new Error("Media exceeds 5MB limit");
|
||||
}
|
||||
await fs.copyFile(source, dest);
|
||||
return { id, path: dest, size: stat.size };
|
||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await cleanOldMedia();
|
||||
const id = crypto.randomUUID();
|
||||
const dest = path.join(dir, id);
|
||||
if (looksLikeUrl(source)) {
|
||||
await downloadToFile(source, dest, headers);
|
||||
const stat = await fs.stat(dest);
|
||||
return { id, path: dest, size: stat.size };
|
||||
}
|
||||
// local path
|
||||
const stat = await fs.stat(source);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Media path is not a file");
|
||||
}
|
||||
if (stat.size > MAX_BYTES) {
|
||||
throw new Error("Media exceeds 5MB limit");
|
||||
}
|
||||
await fs.copyFile(source, dest);
|
||||
return { id, path: dest, size: stat.size };
|
||||
}
|
||||
|
||||
export async function saveMediaBuffer(
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
subdir = "inbound",
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
subdir = "inbound",
|
||||
): Promise<SavedMedia> {
|
||||
if (buffer.byteLength > MAX_BYTES) {
|
||||
throw new Error("Media exceeds 5MB limit");
|
||||
}
|
||||
const dir = path.join(MEDIA_DIR, subdir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const id = crypto.randomUUID();
|
||||
const dest = path.join(dir, id);
|
||||
await fs.writeFile(dest, buffer);
|
||||
return { id, path: dest, size: buffer.byteLength, contentType };
|
||||
if (buffer.byteLength > MAX_BYTES) {
|
||||
throw new Error("Media exceeds 5MB limit");
|
||||
}
|
||||
const dir = path.join(MEDIA_DIR, subdir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const id = crypto.randomUUID();
|
||||
const dest = path.join(dir, id);
|
||||
await fs.writeFile(dest, buffer);
|
||||
return { id, path: dest, size: buffer.byteLength, contentType };
|
||||
}
|
||||
|
||||
@@ -3,53 +3,53 @@ import { describe, expect, it } from "vitest";
|
||||
import { enqueueCommand, getQueueSize } from "./command-queue.js";
|
||||
|
||||
describe("command queue", () => {
|
||||
it("runs tasks one at a time in order", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const calls: number[] = [];
|
||||
it("runs tasks one at a time in order", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const calls: number[] = [];
|
||||
|
||||
const makeTask = (id: number) => async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
calls.push(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
active -= 1;
|
||||
return id;
|
||||
};
|
||||
const makeTask = (id: number) => async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
calls.push(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
active -= 1;
|
||||
return id;
|
||||
};
|
||||
|
||||
const results = await Promise.all([
|
||||
enqueueCommand(makeTask(1)),
|
||||
enqueueCommand(makeTask(2)),
|
||||
enqueueCommand(makeTask(3)),
|
||||
]);
|
||||
const results = await Promise.all([
|
||||
enqueueCommand(makeTask(1)),
|
||||
enqueueCommand(makeTask(2)),
|
||||
enqueueCommand(makeTask(3)),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
expect(maxActive).toBe(1);
|
||||
expect(getQueueSize()).toBe(0);
|
||||
});
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
expect(maxActive).toBe(1);
|
||||
expect(getQueueSize()).toBe(0);
|
||||
});
|
||||
|
||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||
let waited: number | null = null;
|
||||
let queuedAhead: number | null = null;
|
||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||
let waited: number | null = null;
|
||||
let queuedAhead: number | null = null;
|
||||
|
||||
// First task holds the queue long enough to trigger wait notice.
|
||||
const first = enqueueCommand(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
// First task holds the queue long enough to trigger wait notice.
|
||||
const first = enqueueCommand(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
|
||||
const second = enqueueCommand(async () => {}, {
|
||||
warnAfterMs: 5,
|
||||
onWait: (ms, ahead) => {
|
||||
waited = ms;
|
||||
queuedAhead = ahead;
|
||||
},
|
||||
});
|
||||
const second = enqueueCommand(async () => {}, {
|
||||
warnAfterMs: 5,
|
||||
onWait: (ms, ahead) => {
|
||||
waited = ms;
|
||||
queuedAhead = ahead;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([first, second]);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(waited).not.toBeNull();
|
||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||
expect(queuedAhead).toBe(0);
|
||||
});
|
||||
expect(waited).not.toBeNull();
|
||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||
expect(queuedAhead).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,57 +2,57 @@
|
||||
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
||||
|
||||
type QueueEntry = {
|
||||
task: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
enqueuedAt: number;
|
||||
warnAfterMs: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
task: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
enqueuedAt: number;
|
||||
warnAfterMs: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
};
|
||||
|
||||
const queue: QueueEntry[] = [];
|
||||
let draining = false;
|
||||
|
||||
async function drainQueue() {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
while (queue.length) {
|
||||
const entry = queue.shift() as QueueEntry;
|
||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||
if (waitedMs >= entry.warnAfterMs) {
|
||||
entry.onWait?.(waitedMs, queue.length);
|
||||
}
|
||||
try {
|
||||
const result = await entry.task();
|
||||
entry.resolve(result);
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
draining = false;
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
while (queue.length) {
|
||||
const entry = queue.shift() as QueueEntry;
|
||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||
if (waitedMs >= entry.warnAfterMs) {
|
||||
entry.onWait?.(waitedMs, queue.length);
|
||||
}
|
||||
try {
|
||||
const result = await entry.task();
|
||||
entry.resolve(result);
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
draining = false;
|
||||
}
|
||||
|
||||
export function enqueueCommand<T>(
|
||||
task: () => Promise<T>,
|
||||
opts?: {
|
||||
warnAfterMs?: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
task: () => Promise<T>,
|
||||
opts?: {
|
||||
warnAfterMs?: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
): Promise<T> {
|
||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
queue.push({
|
||||
task: () => task(),
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
enqueuedAt: Date.now(),
|
||||
warnAfterMs,
|
||||
onWait: opts?.onWait,
|
||||
});
|
||||
void drainQueue();
|
||||
});
|
||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
queue.push({
|
||||
task: () => task(),
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
enqueuedAt: Date.now(),
|
||||
warnAfterMs,
|
||||
onWait: opts?.onWait,
|
||||
});
|
||||
void drainQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export function getQueueSize() {
|
||||
return queue.length + (draining ? 1 : 0);
|
||||
return queue.length + (draining ? 1 : 0);
|
||||
}
|
||||
|
||||
+69
-69
@@ -8,86 +8,86 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
// Simple promise-wrapped execFile with optional verbosity logging.
|
||||
export async function runExec(
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const options =
|
||||
typeof opts === "number"
|
||||
? { timeout: opts, encoding: "utf8" as const }
|
||||
: {
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: opts.maxBuffer,
|
||||
encoding: "utf8" as const,
|
||||
};
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) logDebug(stdout.trim());
|
||||
if (stderr.trim()) logError(stderr.trim());
|
||||
}
|
||||
return { stdout, stderr };
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const options =
|
||||
typeof opts === "number"
|
||||
? { timeout: opts, encoding: "utf8" as const }
|
||||
: {
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: opts.maxBuffer,
|
||||
encoding: "utf8" as const,
|
||||
};
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) logDebug(stdout.trim());
|
||||
if (stderr.trim()) logError(stderr.trim());
|
||||
}
|
||||
return { stdout, stderr };
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type SpawnResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
};
|
||||
|
||||
export type CommandOptions = {
|
||||
timeoutMs: number;
|
||||
cwd?: string;
|
||||
timeoutMs: number;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export async function runCommandWithTimeout(
|
||||
argv: string[],
|
||||
optionsOrTimeout: number | CommandOptions,
|
||||
argv: string[],
|
||||
optionsOrTimeout: number | CommandOptions,
|
||||
): Promise<SpawnResult> {
|
||||
const options: CommandOptions =
|
||||
typeof optionsOrTimeout === "number"
|
||||
? { timeoutMs: optionsOrTimeout }
|
||||
: optionsOrTimeout;
|
||||
const { timeoutMs, cwd } = options;
|
||||
const options: CommandOptions =
|
||||
typeof optionsOrTimeout === "number"
|
||||
? { timeoutMs: optionsOrTimeout }
|
||||
: optionsOrTimeout;
|
||||
const { timeoutMs, cwd } = options;
|
||||
|
||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
cwd,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
cwd,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (d) => {
|
||||
stdout += d.toString();
|
||||
});
|
||||
child.stderr?.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
||||
});
|
||||
});
|
||||
child.stdout?.on("data", (d) => {
|
||||
stdout += d.toString();
|
||||
});
|
||||
child.stderr?.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import * as mod from "./provider-web.js";
|
||||
|
||||
describe("provider-web barrel", () => {
|
||||
it("exports the expected web helpers", () => {
|
||||
expect(mod.createWaSocket).toBeTypeOf("function");
|
||||
expect(mod.loginWeb).toBeTypeOf("function");
|
||||
expect(mod.monitorWebProvider).toBeTypeOf("function");
|
||||
expect(mod.sendMessageWeb).toBeTypeOf("function");
|
||||
expect(mod.monitorWebInbox).toBeTypeOf("function");
|
||||
expect(mod.pickProvider).toBeTypeOf("function");
|
||||
expect(mod.WA_WEB_AUTH_DIR).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,469 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MockBaileysSocket } from "../test/mocks/baileys.js";
|
||||
import { createMockBaileys } from "../test/mocks/baileys.js";
|
||||
|
||||
vi.mock("@whiskeysockets/baileys", () => {
|
||||
const created = createMockBaileys();
|
||||
(globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("warelay:lastSocket")
|
||||
] = created.lastSocket;
|
||||
return created.mod;
|
||||
});
|
||||
|
||||
vi.mock("./media/store.js", () => ({
|
||||
saveMediaBuffer: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_buf: Buffer, contentType?: string) => ({
|
||||
id: "mid",
|
||||
path: "/tmp/mid",
|
||||
size: _buf.length,
|
||||
contentType,
|
||||
})),
|
||||
}));
|
||||
|
||||
function getLastSocket(): MockBaileysSocket {
|
||||
const getter = (globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("warelay:lastSocket")
|
||||
];
|
||||
if (typeof getter === "function")
|
||||
return (getter as () => MockBaileysSocket)();
|
||||
if (!getter) throw new Error("Baileys mock not initialized");
|
||||
throw new Error("Invalid Baileys socket getter");
|
||||
}
|
||||
|
||||
vi.mock("qrcode-terminal", () => ({
|
||||
default: { generate: vi.fn() },
|
||||
generate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { monitorWebProvider } from "./index.js";
|
||||
import { resetLogger, setLoggerOverride } from "./logging.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
loginWeb,
|
||||
logWebSelfId,
|
||||
monitorWebInbox,
|
||||
sendMessageWeb,
|
||||
waitForWaConnection,
|
||||
} from "./provider-web.js";
|
||||
|
||||
const baileys = (await import(
|
||||
"@whiskeysockets/baileys"
|
||||
)) as unknown as typeof import("@whiskeysockets/baileys") & {
|
||||
makeWASocket: ReturnType<typeof vi.fn>;
|
||||
useMultiFileAuthState: ReturnType<typeof vi.fn>;
|
||||
fetchLatestBaileysVersion: ReturnType<typeof vi.fn>;
|
||||
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe("provider-web", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const recreated = createMockBaileys();
|
||||
(globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("warelay:lastSocket")
|
||||
] = recreated.lastSocket;
|
||||
baileys.makeWASocket.mockImplementation(recreated.mod.makeWASocket);
|
||||
baileys.useMultiFileAuthState.mockImplementation(
|
||||
recreated.mod.useMultiFileAuthState,
|
||||
);
|
||||
baileys.fetchLatestBaileysVersion.mockImplementation(
|
||||
recreated.mod.fetchLatestBaileysVersion,
|
||||
);
|
||||
baileys.makeCacheableSignalKeyStore.mockImplementation(
|
||||
recreated.mod.makeCacheableSignalKeyStore,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
});
|
||||
|
||||
it("creates WA socket with QR handler", async () => {
|
||||
await createWaSocket(true, false);
|
||||
const makeWASocket = baileys.makeWASocket as ReturnType<typeof vi.fn>;
|
||||
expect(makeWASocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ printQRInTerminal: false }),
|
||||
);
|
||||
const passed = makeWASocket.mock.calls[0][0];
|
||||
const passedLogger = (
|
||||
passed as { logger?: { level?: string; trace?: unknown } }
|
||||
).logger;
|
||||
expect(passedLogger?.level).toBe("silent");
|
||||
expect(typeof passedLogger?.trace).toBe("function");
|
||||
const sock = getLastSocket();
|
||||
const saveCreds = (
|
||||
await baileys.useMultiFileAuthState.mock.results[0].value
|
||||
).saveCreds;
|
||||
// trigger creds.update listener
|
||||
sock.ev.emit("creds.update", {});
|
||||
expect(saveCreds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for connection open", async () => {
|
||||
const ev = new EventEmitter();
|
||||
const promise = waitForWaConnection({ ev } as unknown as ReturnType<
|
||||
typeof baileys.makeWASocket
|
||||
>);
|
||||
ev.emit("connection.update", { connection: "open" });
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects when connection closes", async () => {
|
||||
const ev = new EventEmitter();
|
||||
const promise = waitForWaConnection({ ev } as unknown as ReturnType<
|
||||
typeof baileys.makeWASocket
|
||||
>);
|
||||
ev.emit("connection.update", {
|
||||
connection: "close",
|
||||
lastDisconnect: new Error("bye"),
|
||||
});
|
||||
await expect(promise).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("sends message via web and closes socket", async () => {
|
||||
await sendMessageWeb("+1555", "hi", { verbose: false });
|
||||
const sock = getLastSocket();
|
||||
expect(sock.sendMessage).toHaveBeenCalled();
|
||||
expect(sock.ws.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loginWeb waits for connection and closes", async () => {
|
||||
const closeSpy = vi.fn();
|
||||
const ev = new EventEmitter();
|
||||
baileys.makeWASocket.mockImplementation(() => ({
|
||||
ev,
|
||||
ws: { close: closeSpy },
|
||||
sendPresenceUpdate: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
const waiter: typeof waitForWaConnection = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
await loginWeb(false, waiter);
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("monitorWebInbox streams inbound messages", async () => {
|
||||
const onMessage = vi.fn(async (msg) => {
|
||||
await msg.sendComposing();
|
||||
await msg.reply("pong");
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = getLastSocket();
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "ping", from: "+999", to: "+123" }),
|
||||
);
|
||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
id: "abc",
|
||||
participant: undefined,
|
||||
fromMe: false,
|
||||
},
|
||||
]);
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith(
|
||||
"composing",
|
||||
"999@s.whatsapp.net",
|
||||
);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: "pong",
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("monitorWebInbox captures media path for image messages", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = getLastSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" },
|
||||
message: { imageMessage: { mimetype: "image/jpeg" } },
|
||||
messageTimestamp: 1_700_000_100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "<media:image>",
|
||||
mediaPath: "/tmp/mid",
|
||||
mediaType: "image/jpeg",
|
||||
}),
|
||||
);
|
||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
remoteJid: "888@s.whatsapp.net",
|
||||
id: "med1",
|
||||
participant: undefined,
|
||||
fromMe: false,
|
||||
},
|
||||
]);
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("monitorWebInbox resolves onClose when the socket closes", async () => {
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
const sock = getLastSocket();
|
||||
const reasonPromise = listener.onClose;
|
||||
sock.ev.emit("connection.update", {
|
||||
connection: "close",
|
||||
lastDisconnect: { error: { output: { statusCode: 500 } } },
|
||||
});
|
||||
await expect(reasonPromise).resolves.toEqual(
|
||||
expect.objectContaining({ status: 500, isLoggedOut: false }),
|
||||
);
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("monitorWebInbox logs inbound bodies to file", async () => {
|
||||
const logPath = path.join(
|
||||
os.tmpdir(),
|
||||
`warelay-log-test-${crypto.randomUUID()}.log`,
|
||||
);
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = getLastSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const content = fsSync.readFileSync(logPath, "utf-8");
|
||||
expect(content).toContain('"module":"web-inbound"');
|
||||
expect(content).toContain('"body":"ping"');
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("monitorWebInbox includes participant when marking group messages read", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = getLastSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp1",
|
||||
fromMe: false,
|
||||
remoteJid: "12345-67890@g.us",
|
||||
participant: "111@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "group ping" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
remoteJid: "12345-67890@g.us",
|
||||
id: "grp1",
|
||||
participant: "111@s.whatsapp.net",
|
||||
fromMe: false,
|
||||
},
|
||||
]);
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("monitorWebProvider reconnects after a connection close", async () => {
|
||||
vi.useFakeTimers();
|
||||
const closeResolvers: Array<() => void> = [];
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
let resolve!: () => void;
|
||||
const onClose = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
closeResolvers.push(res);
|
||||
});
|
||||
return { close: vi.fn(), onClose };
|
||||
});
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = monitorWebProvider(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeResolvers[0]?.();
|
||||
await Promise.resolve();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Reconnecting"),
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
closeResolvers[1]?.();
|
||||
await vi.runAllTimersAsync();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("monitorWebProvider falls back to text when media send fails", async () => {
|
||||
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/img.png",
|
||||
});
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./provider-web.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./provider-web.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(1024),
|
||||
headers: { get: () => "image/png" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith("hi");
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it("logs outbound replies to file", async () => {
|
||||
const logPath = path.join(
|
||||
os.tmpdir(),
|
||||
`warelay-log-test-${crypto.randomUUID()}.log`,
|
||||
);
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./provider-web.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./provider-web.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
const content = fsSync.readFileSync(logPath, "utf-8");
|
||||
expect(content).toContain('"module":"web-auto-reply"');
|
||||
expect(content).toContain('"text":"auto"');
|
||||
});
|
||||
|
||||
it("logWebSelfId prints cached E.164 when creds exist", () => {
|
||||
const existsSpy = vi
|
||||
.spyOn(fsSync, "existsSync")
|
||||
.mockReturnValue(true as never);
|
||||
const readSpy = vi
|
||||
.spyOn(fsSync, "readFileSync")
|
||||
.mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } }));
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
logWebSelfId(runtime as never, true);
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"Web Provider: +12345 (jid 12345@s.whatsapp.net)",
|
||||
);
|
||||
existsSpy.mockRestore();
|
||||
readSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+32
-766
@@ -1,766 +1,32 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { proto } from "@whiskeysockets/baileys";
|
||||
import {
|
||||
type AnyMessageContent,
|
||||
DisconnectReason,
|
||||
downloadMediaMessage,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
type WAMessage,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import qrcode from "qrcode-terminal";
|
||||
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { danger, info, isVerbose, logVerbose, success } from "./globals.js";
|
||||
import { logInfo } from "./logger.js";
|
||||
import { getChildLogger } from "./logging.js";
|
||||
import { saveMediaBuffer } from "./media/store.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||
import type { Provider } from "./utils.js";
|
||||
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
|
||||
import { VERSION } from "./version.js";
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||
}
|
||||
|
||||
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
|
||||
|
||||
export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
||||
const logger = getChildLogger(
|
||||
{ module: "baileys" },
|
||||
{
|
||||
level: verbose ? "info" : "silent",
|
||||
},
|
||||
);
|
||||
// Some Baileys internals call logger.trace even when silent; ensure it's present.
|
||||
const loggerAny = logger as unknown as Record<string, unknown>;
|
||||
if (typeof loggerAny.trace !== "function") {
|
||||
loggerAny.trace = () => {};
|
||||
}
|
||||
await ensureDir(WA_WEB_AUTH_DIR);
|
||||
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
const sock = makeWASocket({
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||
},
|
||||
version,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
browser: ["warelay", "cli", VERSION],
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
});
|
||||
|
||||
sock.ev.on("creds.update", saveCreds);
|
||||
sock.ev.on(
|
||||
"connection.update",
|
||||
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
if (qr && printQr) {
|
||||
console.log("Scan this QR in WhatsApp (Linked Devices):");
|
||||
qrcode.generate(qr, { small: true });
|
||||
}
|
||||
if (connection === "close") {
|
||||
const status = getStatusCode(lastDisconnect?.error);
|
||||
if (status === DisconnectReason.loggedOut) {
|
||||
console.error(
|
||||
danger("WhatsApp session logged out. Run: warelay login"),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (connection === "open" && verbose) {
|
||||
console.log(success("WhatsApp Web connected."));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return sock;
|
||||
}
|
||||
|
||||
export async function waitForWaConnection(
|
||||
sock: ReturnType<typeof makeWASocket>,
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
type OffCapable = {
|
||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
const evWithOff = sock.ev as unknown as OffCapable;
|
||||
|
||||
const handler = (...args: unknown[]) => {
|
||||
const update = (args[0] ?? {}) as Partial<
|
||||
import("@whiskeysockets/baileys").ConnectionState
|
||||
>;
|
||||
if (update.connection === "open") {
|
||||
evWithOff.off?.("connection.update", handler);
|
||||
resolve();
|
||||
}
|
||||
if (update.connection === "close") {
|
||||
evWithOff.off?.("connection.update", handler);
|
||||
reject(update.lastDisconnect ?? new Error("Connection closed"));
|
||||
}
|
||||
};
|
||||
|
||||
sock.ev.on("connection.update", handler);
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMessageWeb(
|
||||
to: string,
|
||||
body: string,
|
||||
options: { verbose: boolean; mediaUrl?: string },
|
||||
): Promise<{ messageId: string; toJid: string }> {
|
||||
const sock = await createWaSocket(false, options.verbose);
|
||||
try {
|
||||
logInfo("🔌 Connecting to WhatsApp Web…");
|
||||
await waitForWaConnection(sock);
|
||||
const jid = toWhatsappJid(to);
|
||||
try {
|
||||
await sock.sendPresenceUpdate("composing", jid);
|
||||
} catch (err) {
|
||||
logVerbose(`Presence update skipped: ${String(err)}`);
|
||||
}
|
||||
let payload: AnyMessageContent = { text: body };
|
||||
if (options.mediaUrl) {
|
||||
const media = await loadWebMedia(options.mediaUrl);
|
||||
payload = {
|
||||
image: media.buffer,
|
||||
caption: body || undefined,
|
||||
mimetype: media.contentType,
|
||||
};
|
||||
}
|
||||
logInfo(
|
||||
`📤 Sending via web session -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
|
||||
);
|
||||
const result = await sock.sendMessage(jid, payload);
|
||||
const messageId = result?.key?.id ?? "unknown";
|
||||
logInfo(
|
||||
`✅ Sent via web session. Message ID: ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
|
||||
);
|
||||
return { messageId, toJid: jid };
|
||||
} finally {
|
||||
try {
|
||||
sock.ws?.close();
|
||||
} catch (err) {
|
||||
logVerbose(`Socket close failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWeb(
|
||||
verbose: boolean,
|
||||
waitForConnection: typeof waitForWaConnection = waitForWaConnection,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const sock = await createWaSocket(true, verbose);
|
||||
logInfo("Waiting for WhatsApp connection...", runtime);
|
||||
try {
|
||||
await waitForConnection(sock);
|
||||
console.log(success("✅ Linked! Credentials saved for future sends."));
|
||||
} catch (err) {
|
||||
const code =
|
||||
(err as { error?: { output?: { statusCode?: number } } })?.error?.output
|
||||
?.statusCode ??
|
||||
(err as { output?: { statusCode?: number } })?.output?.statusCode;
|
||||
if (code === 515) {
|
||||
console.log(
|
||||
info(
|
||||
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
|
||||
),
|
||||
);
|
||||
try {
|
||||
sock.ws?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const retry = await createWaSocket(false, verbose);
|
||||
try {
|
||||
await waitForConnection(retry);
|
||||
console.log(
|
||||
success(
|
||||
"✅ Linked after restart; web session ready. You can now send with provider=web.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setTimeout(() => retry.ws?.close(), 500);
|
||||
}
|
||||
}
|
||||
if (code === DisconnectReason.loggedOut) {
|
||||
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
|
||||
console.error(
|
||||
danger(
|
||||
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun warelay login and scan the QR again.",
|
||||
),
|
||||
);
|
||||
throw new Error("Session logged out; cache cleared. Re-run login.");
|
||||
}
|
||||
const formatted = formatError(err);
|
||||
console.error(
|
||||
danger(
|
||||
`WhatsApp Web connection ended before fully opening. ${formatted}`,
|
||||
),
|
||||
);
|
||||
throw new Error(formatted);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
sock.ws?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export { WA_WEB_AUTH_DIR };
|
||||
|
||||
export function webAuthExists() {
|
||||
return fs
|
||||
.access(WA_WEB_AUTH_DIR)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
type WebListenerCloseReason = {
|
||||
status?: number;
|
||||
isLoggedOut: boolean;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type WebInboundMessage = {
|
||||
id?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
body: string;
|
||||
pushName?: string;
|
||||
timestamp?: number;
|
||||
sendComposing: () => Promise<void>;
|
||||
reply: (text: string) => Promise<void>;
|
||||
sendMedia: (payload: {
|
||||
image: Buffer;
|
||||
caption?: string;
|
||||
mimetype?: string;
|
||||
}) => Promise<void>;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
mediaUrl?: string;
|
||||
};
|
||||
|
||||
export async function monitorWebInbox(options: {
|
||||
verbose: boolean;
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
}) {
|
||||
const inboundLogger = getChildLogger({ module: "web-inbound" });
|
||||
const sock = await createWaSocket(false, options.verbose);
|
||||
await waitForWaConnection(sock);
|
||||
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
|
||||
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
|
||||
onCloseResolve = resolve;
|
||||
});
|
||||
try {
|
||||
// Advertise that the relay is online right after connecting.
|
||||
await sock.sendPresenceUpdate("available");
|
||||
if (isVerbose()) logVerbose("Sent global 'available' presence on connect");
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`Failed to send 'available' presence on connect: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
const selfJid = sock.user?.id;
|
||||
const selfE164 = selfJid ? jidToE164(selfJid) : null;
|
||||
const seen = new Set<string>();
|
||||
|
||||
sock.ev.on("messages.upsert", async (upsert) => {
|
||||
if (upsert.type !== "notify") return;
|
||||
for (const msg of upsert.messages) {
|
||||
const id = msg.key?.id ?? undefined;
|
||||
// De-dupe on message id; Baileys can emit retries.
|
||||
if (id && seen.has(id)) continue;
|
||||
if (id) seen.add(id);
|
||||
if (msg.key?.fromMe) continue;
|
||||
const remoteJid = msg.key?.remoteJid;
|
||||
if (!remoteJid) continue;
|
||||
// Ignore status/broadcast traffic; we only care about direct chats.
|
||||
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
||||
continue;
|
||||
if (id) {
|
||||
const participant = msg.key?.participant;
|
||||
try {
|
||||
await sock.readMessages([
|
||||
{ remoteJid, id, participant, fromMe: false },
|
||||
]);
|
||||
if (isVerbose()) {
|
||||
const suffix = participant ? ` (participant ${participant})` : "";
|
||||
logVerbose(
|
||||
`Marked message ${id} as read for ${remoteJid}${suffix}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
const from = jidToE164(remoteJid);
|
||||
if (!from) continue;
|
||||
let body = extractText(msg.message ?? undefined);
|
||||
if (!body) {
|
||||
body = extractMediaPlaceholder(msg.message ?? undefined);
|
||||
if (!body) continue;
|
||||
}
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
try {
|
||||
const inboundMedia = await downloadInboundMedia(msg, sock);
|
||||
if (inboundMedia) {
|
||||
const saved = await saveMediaBuffer(
|
||||
inboundMedia.buffer,
|
||||
inboundMedia.mimetype,
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
mediaType = inboundMedia.mimetype;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Inbound media download failed: ${String(err)}`);
|
||||
}
|
||||
const chatJid = remoteJid;
|
||||
const sendComposing = async () => {
|
||||
try {
|
||||
await sock.sendPresenceUpdate("composing", chatJid);
|
||||
} catch (err) {
|
||||
logVerbose(`Presence update failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
const reply = async (text: string) => {
|
||||
await sock.sendMessage(chatJid, { text });
|
||||
};
|
||||
const sendMedia = async (payload: {
|
||||
image: Buffer;
|
||||
caption?: string;
|
||||
mimetype?: string;
|
||||
}) => {
|
||||
await sock.sendMessage(chatJid, payload);
|
||||
};
|
||||
const timestamp = msg.messageTimestamp
|
||||
? Number(msg.messageTimestamp) * 1000
|
||||
: undefined;
|
||||
inboundLogger.info(
|
||||
{
|
||||
from,
|
||||
to: selfE164 ?? "me",
|
||||
body,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
timestamp,
|
||||
},
|
||||
"inbound message",
|
||||
);
|
||||
try {
|
||||
await options.onMessage({
|
||||
id,
|
||||
from,
|
||||
to: selfE164 ?? "me",
|
||||
body,
|
||||
pushName: msg.pushName ?? undefined,
|
||||
timestamp,
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
danger(`Failed handling inbound web message: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on(
|
||||
"connection.update",
|
||||
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
|
||||
if (update.connection === "close") {
|
||||
const status = getStatusCode(update.lastDisconnect?.error);
|
||||
onCloseResolve?.({
|
||||
status,
|
||||
isLoggedOut: status === DisconnectReason.loggedOut,
|
||||
error: update.lastDisconnect?.error,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
try {
|
||||
sock.ws?.close();
|
||||
} catch (err) {
|
||||
logVerbose(`Socket close failed: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
onClose,
|
||||
};
|
||||
}
|
||||
|
||||
export async function monitorWebProvider(
|
||||
verbose: boolean,
|
||||
listenerFactory = monitorWebInbox,
|
||||
keepAlive = true,
|
||||
replyResolver: typeof getReplyFromConfig = getReplyFromConfig,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
const replyLogger = getChildLogger({ module: "web-auto-reply" });
|
||||
const stopRequested = () => abortSignal?.aborted === true;
|
||||
const abortPromise =
|
||||
abortSignal &&
|
||||
new Promise<"aborted">((resolve) =>
|
||||
abortSignal.addEventListener("abort", () => resolve("aborted"), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
while (true) {
|
||||
if (stopRequested()) break;
|
||||
|
||||
const listener = await listenerFactory({
|
||||
verbose,
|
||||
onMessage: async (msg) => {
|
||||
const ts = msg.timestamp
|
||||
? new Date(msg.timestamp).toISOString()
|
||||
: new Date().toISOString();
|
||||
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
||||
|
||||
const replyStarted = Date.now();
|
||||
const replyResult = await replyResolver(
|
||||
{
|
||||
Body: msg.body,
|
||||
From: msg.from,
|
||||
To: msg.to,
|
||||
MessageSid: msg.id,
|
||||
MediaPath: msg.mediaPath,
|
||||
MediaUrl: msg.mediaUrl,
|
||||
MediaType: msg.mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: msg.sendComposing,
|
||||
},
|
||||
);
|
||||
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) {
|
||||
logVerbose("Skipping auto-reply: no text/media returned from resolver");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (replyResult.mediaUrl) {
|
||||
logVerbose(
|
||||
`Web auto-reply media detected: ${replyResult.mediaUrl}`,
|
||||
);
|
||||
try {
|
||||
const media = await loadWebMedia(replyResult.mediaUrl);
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
|
||||
);
|
||||
}
|
||||
await msg.sendMedia({
|
||||
image: media.buffer,
|
||||
caption: replyResult.text || undefined,
|
||||
mimetype: media.contentType,
|
||||
});
|
||||
logInfo(
|
||||
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
|
||||
runtime,
|
||||
);
|
||||
replyLogger.info(
|
||||
{
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: replyResult.text ?? null,
|
||||
mediaUrl: replyResult.mediaUrl,
|
||||
mediaSizeBytes: media.buffer.length,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
},
|
||||
"auto-reply sent (media)",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
danger(
|
||||
`Failed sending web media to ${msg.from}: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
if (replyResult.text) {
|
||||
await msg.reply(replyResult.text);
|
||||
logInfo(
|
||||
`⚠️ Media skipped; sent text-only to ${msg.from}`,
|
||||
runtime,
|
||||
);
|
||||
replyLogger.info(
|
||||
{
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: replyResult.text,
|
||||
mediaUrl: replyResult.mediaUrl,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
mediaSendFailed: true,
|
||||
},
|
||||
"auto-reply sent (text fallback)",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await msg.reply(replyResult.text ?? "");
|
||||
}
|
||||
const durationMs = Date.now() - replyStarted;
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
success(
|
||||
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${replyResult.mediaUrl ? ", media" : ""}, ${formatDuration(durationMs)})`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
success(
|
||||
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl ? " (media)" : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
replyLogger.info(
|
||||
{
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: replyResult.text ?? null,
|
||||
mediaUrl: replyResult.mediaUrl,
|
||||
durationMs,
|
||||
},
|
||||
"auto-reply sent",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
danger(
|
||||
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
logInfo(
|
||||
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
||||
runtime,
|
||||
);
|
||||
let stop = false;
|
||||
process.on("SIGINT", () => {
|
||||
stop = true;
|
||||
void listener.close().finally(() => {
|
||||
logInfo("👋 Web monitor stopped", runtime);
|
||||
runtime.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
if (!keepAlive) return;
|
||||
|
||||
const reason = await Promise.race([
|
||||
listener.onClose ?? waitForever(),
|
||||
abortPromise ?? waitForever(),
|
||||
]);
|
||||
|
||||
if (stopRequested() || stop || reason === "aborted") {
|
||||
await listener.close();
|
||||
break;
|
||||
}
|
||||
|
||||
const status =
|
||||
(typeof reason === "object" && reason && "status" in reason
|
||||
? (reason as WebListenerCloseReason).status
|
||||
: undefined) ?? "unknown";
|
||||
const loggedOut =
|
||||
typeof reason === "object" &&
|
||||
reason &&
|
||||
"isLoggedOut" in reason &&
|
||||
(reason as WebListenerCloseReason).isLoggedOut;
|
||||
|
||||
if (loggedOut) {
|
||||
runtime.error(
|
||||
danger(
|
||||
"WhatsApp session logged out. Run `warelay login --provider web` to relink.",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
runtime.error(
|
||||
danger(
|
||||
`WhatsApp Web connection closed (status ${status}). Reconnecting in 2s…`,
|
||||
),
|
||||
);
|
||||
await listener.close();
|
||||
await sleep(2_000);
|
||||
}
|
||||
}
|
||||
|
||||
function readWebSelfId() {
|
||||
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
|
||||
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
|
||||
try {
|
||||
if (!fsSync.existsSync(credsPath)) {
|
||||
return { e164: null, jid: null };
|
||||
}
|
||||
const raw = fsSync.readFileSync(credsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
|
||||
const jid = parsed?.me?.id ?? null;
|
||||
const e164 = jid ? jidToE164(jid) : null;
|
||||
return { e164, jid };
|
||||
} catch {
|
||||
return { e164: null, jid: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function logWebSelfId(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
includeProviderPrefix = false,
|
||||
) {
|
||||
// Human-friendly log of the currently linked personal web session.
|
||||
const { e164, jid } = readWebSelfId();
|
||||
const details =
|
||||
e164 || jid
|
||||
? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}`
|
||||
: "unknown";
|
||||
const prefix = includeProviderPrefix ? "Web Provider: " : "";
|
||||
runtime.log(info(`${prefix}${details}`));
|
||||
}
|
||||
|
||||
export async function pickProvider(pref: Provider | "auto"): Promise<Provider> {
|
||||
// Auto-select web when logged in; otherwise fall back to twilio.
|
||||
if (pref !== "auto") return pref;
|
||||
const hasWeb = await webAuthExists();
|
||||
if (hasWeb) return "web";
|
||||
return "twilio";
|
||||
}
|
||||
|
||||
function extractText(message: proto.IMessage | undefined): string | undefined {
|
||||
if (!message) return undefined;
|
||||
if (typeof message.conversation === "string" && message.conversation.trim()) {
|
||||
return message.conversation.trim();
|
||||
}
|
||||
const extended = message.extendedTextMessage?.text;
|
||||
if (extended?.trim()) return extended.trim();
|
||||
const caption =
|
||||
message.imageMessage?.caption ?? message.videoMessage?.caption;
|
||||
if (caption?.trim()) return caption.trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractMediaPlaceholder(
|
||||
message: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
if (!message) return undefined;
|
||||
if (message.imageMessage) return "<media:image>";
|
||||
if (message.videoMessage) return "<media:video>";
|
||||
if (message.audioMessage) return "<media:audio>";
|
||||
if (message.documentMessage) return "<media:document>";
|
||||
if (message.stickerMessage) return "<media:sticker>";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function downloadInboundMedia(
|
||||
msg: proto.IWebMessageInfo,
|
||||
sock: ReturnType<typeof makeWASocket>,
|
||||
): Promise<{ buffer: Buffer; mimetype?: string } | undefined> {
|
||||
const message = msg.message;
|
||||
if (!message) return undefined;
|
||||
const mimetype =
|
||||
message.imageMessage?.mimetype ??
|
||||
message.videoMessage?.mimetype ??
|
||||
message.documentMessage?.mimetype ??
|
||||
message.audioMessage?.mimetype ??
|
||||
message.stickerMessage?.mimetype ??
|
||||
undefined;
|
||||
if (
|
||||
!message.imageMessage &&
|
||||
!message.videoMessage &&
|
||||
!message.documentMessage &&
|
||||
!message.audioMessage &&
|
||||
!message.stickerMessage
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const buffer = (await downloadMediaMessage(
|
||||
msg as WAMessage,
|
||||
"buffer",
|
||||
{},
|
||||
{
|
||||
reuploadRequest: sock.updateMediaMessage,
|
||||
logger: sock.logger,
|
||||
},
|
||||
)) as Buffer;
|
||||
return { buffer, mimetype };
|
||||
} catch (err) {
|
||||
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebMedia(
|
||||
mediaUrl: string,
|
||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
const MAX_WEB_BYTES = 16 * 1024 * 1024; // 16MB: web provider can handle larger than Twilio
|
||||
if (mediaUrl.startsWith("file://")) {
|
||||
mediaUrl = mediaUrl.replace("file://", "");
|
||||
}
|
||||
if (/^https?:\/\//i.test(mediaUrl)) {
|
||||
const res = await fetch(mediaUrl);
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
|
||||
}
|
||||
const array = Buffer.from(await res.arrayBuffer());
|
||||
if (array.length > MAX_WEB_BYTES) {
|
||||
throw new Error(
|
||||
`Media exceeds ${Math.floor(MAX_WEB_BYTES / (1024 * 1024))}MB limit (got ${(
|
||||
array.length / (1024 * 1024)
|
||||
).toFixed(1)}MB)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
buffer: array,
|
||||
contentType: res.headers.get("content-type") ?? undefined,
|
||||
};
|
||||
}
|
||||
// Local path
|
||||
const data = await fs.readFile(mediaUrl);
|
||||
if (data.length > MAX_WEB_BYTES) {
|
||||
throw new Error(
|
||||
`Media exceeds ${Math.floor(MAX_WEB_BYTES / (1024 * 1024))}MB limit (got ${(
|
||||
data.length / (1024 * 1024)
|
||||
).toFixed(1)}MB)`,
|
||||
);
|
||||
}
|
||||
return { buffer: data };
|
||||
}
|
||||
|
||||
function getStatusCode(err: unknown) {
|
||||
return (
|
||||
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
|
||||
(err as { status?: number })?.status
|
||||
);
|
||||
}
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
const status = getStatusCode(err);
|
||||
const code = (err as { code?: unknown })?.code;
|
||||
if (status || code)
|
||||
return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
|
||||
return String(err);
|
||||
}
|
||||
// Barrel exports for the web provider pieces. Splitting the original 900+ line
|
||||
// module keeps responsibilities small and testable without changing the public API.
|
||||
export {
|
||||
DEFAULT_WEB_MEDIA_BYTES,
|
||||
HEARTBEAT_PROMPT,
|
||||
HEARTBEAT_TOKEN,
|
||||
monitorWebProvider,
|
||||
resolveHeartbeatRecipients,
|
||||
runWebHeartbeatOnce,
|
||||
type WebMonitorTuning,
|
||||
} from "./web/auto-reply.js";
|
||||
export {
|
||||
extractMediaPlaceholder,
|
||||
extractText,
|
||||
monitorWebInbox,
|
||||
type WebInboundMessage,
|
||||
type WebListenerCloseReason,
|
||||
} from "./web/inbound.js";
|
||||
export { loginWeb } from "./web/login.js";
|
||||
export { loadWebMedia, optimizeImageToJpeg } from "./web/media.js";
|
||||
export { sendMessageWeb } from "./web/outbound.js";
|
||||
export {
|
||||
createWaSocket,
|
||||
formatError,
|
||||
getStatusCode,
|
||||
logoutWeb,
|
||||
logWebSelfId,
|
||||
pickProvider,
|
||||
WA_WEB_AUTH_DIR,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
} from "./web/session.js";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export { createClient } from "../../twilio/client.js";
|
||||
export {
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
} from "../../twilio/messages.js";
|
||||
export { monitorTwilio } from "../../twilio/monitor.js";
|
||||
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
||||
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
||||
export { sendTypingIndicator } from "../../twilio/typing.js";
|
||||
export {
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
updateWebhook,
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
updateWebhook,
|
||||
} from "../../twilio/update-webhook.js";
|
||||
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";
|
||||
|
||||
@@ -4,16 +4,16 @@ import * as impl from "../../provider-web.js";
|
||||
import * as entry from "./index.js";
|
||||
|
||||
describe("providers/web entrypoint", () => {
|
||||
it("re-exports web provider helpers", () => {
|
||||
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
||||
expect(entry.loginWeb).toBe(impl.loginWeb);
|
||||
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
||||
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
||||
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||
});
|
||||
it("re-exports web provider helpers", () => {
|
||||
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
||||
expect(entry.loginWeb).toBe(impl.loginWeb);
|
||||
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
||||
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
||||
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-10
@@ -1,13 +1,13 @@
|
||||
/* istanbul ignore file */
|
||||
export {
|
||||
createWaSocket,
|
||||
loginWeb,
|
||||
logWebSelfId,
|
||||
monitorWebInbox,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
sendMessageWeb,
|
||||
WA_WEB_AUTH_DIR,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
createWaSocket,
|
||||
loginWeb,
|
||||
logWebSelfId,
|
||||
monitorWebInbox,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
sendMessageWeb,
|
||||
WA_WEB_AUTH_DIR,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
} from "../../provider-web.js";
|
||||
|
||||
+9
-9
@@ -1,14 +1,14 @@
|
||||
export type RuntimeEnv = {
|
||||
log: typeof console.log;
|
||||
error: typeof console.error;
|
||||
exit: (code: number) => never;
|
||||
log: typeof console.log;
|
||||
error: typeof console.error;
|
||||
exit: (code: number) => never;
|
||||
};
|
||||
|
||||
export const defaultRuntime: RuntimeEnv = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code) => {
|
||||
process.exit(code);
|
||||
throw new Error("unreachable"); // satisfies tests when mocked
|
||||
},
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code) => {
|
||||
process.exit(code);
|
||||
throw new Error("unreachable"); // satisfies tests when mocked
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,13 +2,13 @@ import Twilio from "twilio";
|
||||
import type { EnvConfig } from "../env.js";
|
||||
|
||||
export function createClient(env: EnvConfig) {
|
||||
// Twilio client using either auth token or API key/secret.
|
||||
if ("authToken" in env.auth) {
|
||||
return Twilio(env.accountSid, env.auth.authToken, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
}
|
||||
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
// Twilio client using either auth token or API key/secret.
|
||||
if ("authToken" in env.auth) {
|
||||
return Twilio(env.accountSid, env.auth.authToken, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
}
|
||||
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
}
|
||||
|
||||
+73
-73
@@ -3,97 +3,97 @@ import { withWhatsAppPrefix } from "../utils.js";
|
||||
import { createClient } from "./client.js";
|
||||
|
||||
export type ListedMessage = {
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
// Remove duplicates by SID while preserving order.
|
||||
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: ListedMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (seen.has(m.sid)) continue;
|
||||
seen.add(m.sid);
|
||||
deduped.push(m);
|
||||
}
|
||||
return deduped;
|
||||
const seen = new Set<string>();
|
||||
const deduped: ListedMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (seen.has(m.sid)) continue;
|
||||
seen.add(m.sid);
|
||||
deduped.push(m);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
// Sort messages newest -> oldest by dateCreated.
|
||||
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
||||
return [...messages].sort((a, b) => {
|
||||
const da = a.dateCreated?.getTime() ?? 0;
|
||||
const db = b.dateCreated?.getTime() ?? 0;
|
||||
return db - da;
|
||||
});
|
||||
return [...messages].sort((a, b) => {
|
||||
const da = a.dateCreated?.getTime() ?? 0;
|
||||
const db = b.dateCreated?.getTime() ?? 0;
|
||||
return db - da;
|
||||
});
|
||||
}
|
||||
|
||||
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
||||
export async function listRecentMessages(
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
): Promise<ListedMessage[]> {
|
||||
const env = readEnv();
|
||||
const client = clientOverride ?? createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||
const env = readEnv();
|
||||
const client = clientOverride ?? createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||
|
||||
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
||||
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
||||
const inbound = await client.messages.list({
|
||||
to: from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
const outbound = await client.messages.list({
|
||||
from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
||||
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
||||
const inbound = await client.messages.list({
|
||||
to: from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
const outbound = await client.messages.list({
|
||||
from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
|
||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||
const combined = uniqueBySid(
|
||||
[...inboundArr, ...outboundArr].map((m) => ({
|
||||
sid: m.sid,
|
||||
status: m.status ?? null,
|
||||
direction: m.direction ?? null,
|
||||
dateCreated: m.dateCreated,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
body: m.body,
|
||||
errorCode: m.errorCode ?? null,
|
||||
errorMessage: m.errorMessage ?? null,
|
||||
})),
|
||||
);
|
||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||
const combined = uniqueBySid(
|
||||
[...inboundArr, ...outboundArr].map((m) => ({
|
||||
sid: m.sid,
|
||||
status: m.status ?? null,
|
||||
direction: m.direction ?? null,
|
||||
dateCreated: m.dateCreated,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
body: m.body,
|
||||
errorCode: m.errorCode ?? null,
|
||||
errorMessage: m.errorMessage ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
return sortByDateDesc(combined).slice(0, limit);
|
||||
return sortByDateDesc(combined).slice(0, limit);
|
||||
}
|
||||
|
||||
// Human-friendly single-line formatter for recent messages.
|
||||
export function formatMessageLine(m: ListedMessage): string {
|
||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||
const dir =
|
||||
m.direction === "inbound"
|
||||
? "⬅️ "
|
||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||
? "➡️ "
|
||||
: "↔️ ";
|
||||
const status = m.status ?? "unknown";
|
||||
const err =
|
||||
m.errorCode != null
|
||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||
: "";
|
||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||
const bodyPreview =
|
||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||
const dir =
|
||||
m.direction === "inbound"
|
||||
? "⬅️ "
|
||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||
? "➡️ "
|
||||
: "↔️ ";
|
||||
const status = m.status ?? "unknown";
|
||||
const err =
|
||||
m.errorCode != null
|
||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||
: "";
|
||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||
const bodyPreview =
|
||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||
}
|
||||
|
||||
+37
-37
@@ -3,43 +3,43 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { monitorTwilio } from "./monitor.js";
|
||||
|
||||
describe("monitorTwilio", () => {
|
||||
it("processes inbound messages once with injected deps", async () => {
|
||||
const listRecentMessages = vi.fn().mockResolvedValue([
|
||||
{
|
||||
sid: "m1",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date(),
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
status: null,
|
||||
},
|
||||
]);
|
||||
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
||||
const readEnv = vi.fn(() => ({
|
||||
accountSid: "AC",
|
||||
whatsappFrom: "whatsapp:+1",
|
||||
auth: { accountSid: "AC", authToken: "t" },
|
||||
}));
|
||||
const createClient = vi.fn(
|
||||
() => ({ messages: { create: vi.fn() } }) as never,
|
||||
);
|
||||
const sleep = vi.fn().mockResolvedValue(undefined);
|
||||
it("processes inbound messages once with injected deps", async () => {
|
||||
const listRecentMessages = vi.fn().mockResolvedValue([
|
||||
{
|
||||
sid: "m1",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date(),
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
status: null,
|
||||
},
|
||||
]);
|
||||
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
||||
const readEnv = vi.fn(() => ({
|
||||
accountSid: "AC",
|
||||
whatsappFrom: "whatsapp:+1",
|
||||
auth: { accountSid: "AC", authToken: "t" },
|
||||
}));
|
||||
const createClient = vi.fn(
|
||||
() => ({ messages: { create: vi.fn() } }) as never,
|
||||
);
|
||||
const sleep = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await monitorTwilio(0, 0, {
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
maxIterations: 1,
|
||||
});
|
||||
await monitorTwilio(0, 0, {
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
maxIterations: 1,
|
||||
});
|
||||
|
||||
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
||||
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
||||
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+96
-96
@@ -8,122 +8,122 @@ import { sleep, withWhatsAppPrefix } from "../utils.js";
|
||||
import { createClient } from "./client.js";
|
||||
|
||||
type MonitorDeps = {
|
||||
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||
listRecentMessages: (
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
) => Promise<ListedMessage[]>;
|
||||
readEnv: typeof readEnv;
|
||||
createClient: typeof createClient;
|
||||
sleep: typeof sleep;
|
||||
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||
listRecentMessages: (
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
) => Promise<ListedMessage[]>;
|
||||
readEnv: typeof readEnv;
|
||||
createClient: typeof createClient;
|
||||
sleep: typeof sleep;
|
||||
};
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||
|
||||
export type ListedMessage = {
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
type MonitorOptions = {
|
||||
client?: ReturnType<typeof createClient>;
|
||||
maxIterations?: number;
|
||||
deps?: MonitorDeps;
|
||||
runtime?: RuntimeEnv;
|
||||
client?: ReturnType<typeof createClient>;
|
||||
maxIterations?: number;
|
||||
deps?: MonitorDeps;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
const defaultDeps: MonitorDeps = {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages: () => Promise.resolve([]),
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages: () => Promise.resolve([]),
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
};
|
||||
|
||||
// Poll Twilio for inbound messages and auto-reply when configured.
|
||||
export async function monitorTwilio(
|
||||
pollSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
opts?: MonitorOptions,
|
||||
pollSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
opts?: MonitorOptions,
|
||||
) {
|
||||
const deps = opts?.deps ?? defaultDeps;
|
||||
const runtime = opts?.runtime ?? defaultRuntime;
|
||||
const maxIterations = opts?.maxIterations ?? Infinity;
|
||||
let backoffMs = 1_000;
|
||||
const deps = opts?.deps ?? defaultDeps;
|
||||
const runtime = opts?.runtime ?? defaultRuntime;
|
||||
const maxIterations = opts?.maxIterations ?? Infinity;
|
||||
let backoffMs = 1_000;
|
||||
|
||||
const env = deps.readEnv(runtime);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const client = opts?.client ?? deps.createClient(env);
|
||||
logInfo(
|
||||
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||
runtime,
|
||||
);
|
||||
const env = deps.readEnv(runtime);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const client = opts?.client ?? deps.createClient(env);
|
||||
logInfo(
|
||||
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||
runtime,
|
||||
);
|
||||
|
||||
let lastSeenSid: string | undefined;
|
||||
let iterations = 0;
|
||||
while (iterations < maxIterations) {
|
||||
let messages: ListedMessage[] = [];
|
||||
try {
|
||||
messages =
|
||||
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||
backoffMs = 1_000; // reset after success
|
||||
} catch (err) {
|
||||
logWarn(
|
||||
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
await deps.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * 2, 10_000);
|
||||
continue;
|
||||
}
|
||||
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
||||
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
||||
const newestFirst = [...inboundOnly].sort(
|
||||
(a, b) =>
|
||||
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
||||
);
|
||||
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
||||
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||
iterations += 1;
|
||||
if (iterations >= maxIterations) break;
|
||||
await deps.sleep(
|
||||
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
||||
);
|
||||
}
|
||||
let lastSeenSid: string | undefined;
|
||||
let iterations = 0;
|
||||
while (iterations < maxIterations) {
|
||||
let messages: ListedMessage[] = [];
|
||||
try {
|
||||
messages =
|
||||
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||
backoffMs = 1_000; // reset after success
|
||||
} catch (err) {
|
||||
logWarn(
|
||||
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
await deps.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * 2, 10_000);
|
||||
continue;
|
||||
}
|
||||
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
||||
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
||||
const newestFirst = [...inboundOnly].sort(
|
||||
(a, b) =>
|
||||
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
||||
);
|
||||
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
||||
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||
iterations += 1;
|
||||
if (iterations >= maxIterations) break;
|
||||
await deps.sleep(
|
||||
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessages(
|
||||
messages: ListedMessage[],
|
||||
client: ReturnType<typeof createClient>,
|
||||
lastSeenSid: string | undefined,
|
||||
deps: MonitorDeps,
|
||||
runtime: RuntimeEnv,
|
||||
messages: ListedMessage[],
|
||||
client: ReturnType<typeof createClient>,
|
||||
lastSeenSid: string | undefined,
|
||||
deps: MonitorDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
for (const m of messages) {
|
||||
if (!m.sid) continue;
|
||||
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
||||
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
||||
if (m.direction !== "inbound") continue;
|
||||
if (!m.from || !m.to) continue;
|
||||
try {
|
||||
await deps.autoReplyIfConfigured(
|
||||
client as unknown as import("./types.js").TwilioRequester & {
|
||||
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||
},
|
||||
m as unknown as MessageInstance,
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
for (const m of messages) {
|
||||
if (!m.sid) continue;
|
||||
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
||||
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
||||
if (m.direction !== "inbound") continue;
|
||||
if (!m.from || !m.to) continue;
|
||||
try {
|
||||
await deps.autoReplyIfConfigured(
|
||||
client as unknown as import("./types.js").TwilioRequester & {
|
||||
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||
},
|
||||
m as unknown as MessageInstance,
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-25
@@ -3,30 +3,30 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { waitForFinalStatus } from "./send.js";
|
||||
|
||||
describe("twilio send helpers", () => {
|
||||
it("waitForFinalStatus resolves on delivered", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "queued" })
|
||||
.mockResolvedValueOnce({ status: "delivered" });
|
||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("waitForFinalStatus resolves on delivered", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "queued" })
|
||||
.mockResolvedValueOnce({ status: "delivered" });
|
||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waitForFinalStatus exits on failure", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||
const runtime = {
|
||||
log: console.log,
|
||||
error: () => {},
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
} as never;
|
||||
await expect(
|
||||
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
it("waitForFinalStatus exits on failure", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||
const runtime = {
|
||||
log: console.log,
|
||||
error: () => {},
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
} as never;
|
||||
await expect(
|
||||
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
+48
-48
@@ -10,60 +10,60 @@ const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
|
||||
|
||||
// Send outbound WhatsApp message; exit non-zero on API failure.
|
||||
export async function sendMessage(
|
||||
to: string,
|
||||
body: string,
|
||||
opts?: { mediaUrl?: string },
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
to: string,
|
||||
body: string,
|
||||
opts?: { mediaUrl?: string },
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const env = readEnv(runtime);
|
||||
const client = createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const toNumber = withWhatsAppPrefix(to);
|
||||
const env = readEnv(runtime);
|
||||
const client = createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const toNumber = withWhatsAppPrefix(to);
|
||||
|
||||
try {
|
||||
const message = await client.messages.create({
|
||||
from,
|
||||
to: toNumber,
|
||||
body,
|
||||
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
||||
});
|
||||
try {
|
||||
const message = await client.messages.create({
|
||||
from,
|
||||
to: toNumber,
|
||||
body,
|
||||
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
||||
});
|
||||
|
||||
logInfo(
|
||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||
runtime,
|
||||
);
|
||||
return { client, sid: message.sid };
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, toNumber, runtime);
|
||||
}
|
||||
logInfo(
|
||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||
runtime,
|
||||
);
|
||||
return { client, sid: message.sid };
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, toNumber, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll message status until delivered/failed or timeout.
|
||||
export async function waitForFinalStatus(
|
||||
client: ReturnType<typeof createClient>,
|
||||
sid: string,
|
||||
timeoutSeconds: number,
|
||||
pollSeconds: number,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
client: ReturnType<typeof createClient>,
|
||||
sid: string,
|
||||
timeoutSeconds: number,
|
||||
pollSeconds: number,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const m = await client.messages(sid).fetch();
|
||||
const status = m.status ?? "unknown";
|
||||
if (successTerminalStatuses.has(status)) {
|
||||
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||
return;
|
||||
}
|
||||
if (failureTerminalStatuses.has(status)) {
|
||||
runtime.error(
|
||||
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
await sleep(pollSeconds * 1000);
|
||||
}
|
||||
logInfo(
|
||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||
runtime,
|
||||
);
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const m = await client.messages(sid).fetch();
|
||||
const status = m.status ?? "unknown";
|
||||
if (successTerminalStatuses.has(status)) {
|
||||
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||
return;
|
||||
}
|
||||
if (failureTerminalStatuses.has(status)) {
|
||||
runtime.error(
|
||||
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
await sleep(pollSeconds * 1000);
|
||||
}
|
||||
logInfo(
|
||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
+45
-45
@@ -4,50 +4,50 @@ import { withWhatsAppPrefix } from "../utils.js";
|
||||
import type { TwilioSenderListClient } from "./types.js";
|
||||
|
||||
export async function findWhatsappSenderSid(
|
||||
client: TwilioSenderListClient,
|
||||
from: string,
|
||||
explicitSenderSid?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
client: TwilioSenderListClient,
|
||||
from: string,
|
||||
explicitSenderSid?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
||||
if (explicitSenderSid) {
|
||||
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
||||
return explicitSenderSid;
|
||||
}
|
||||
try {
|
||||
// Prefer official SDK list helper to avoid request-shape mismatches.
|
||||
// Twilio helper types are broad; we narrow to expected shape.
|
||||
const senderClient = client as unknown as TwilioSenderListClient;
|
||||
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
||||
channel: "whatsapp",
|
||||
pageSize: 50,
|
||||
});
|
||||
if (!senders) {
|
||||
throw new Error('List senders response missing "senders" array');
|
||||
}
|
||||
const match = senders.find(
|
||||
(s) =>
|
||||
(typeof s.senderId === "string" &&
|
||||
s.senderId === withWhatsAppPrefix(from)) ||
|
||||
(typeof s.sender_id === "string" &&
|
||||
s.sender_id === withWhatsAppPrefix(from)),
|
||||
);
|
||||
if (!match || typeof match.sid !== "string") {
|
||||
throw new Error(
|
||||
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
||||
);
|
||||
}
|
||||
return match.sid;
|
||||
} catch (err) {
|
||||
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
||||
if (isVerbose()) {
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
||||
if (explicitSenderSid) {
|
||||
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
||||
return explicitSenderSid;
|
||||
}
|
||||
try {
|
||||
// Prefer official SDK list helper to avoid request-shape mismatches.
|
||||
// Twilio helper types are broad; we narrow to expected shape.
|
||||
const senderClient = client as unknown as TwilioSenderListClient;
|
||||
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
||||
channel: "whatsapp",
|
||||
pageSize: 50,
|
||||
});
|
||||
if (!senders) {
|
||||
throw new Error('List senders response missing "senders" array');
|
||||
}
|
||||
const match = senders.find(
|
||||
(s) =>
|
||||
(typeof s.senderId === "string" &&
|
||||
s.senderId === withWhatsAppPrefix(from)) ||
|
||||
(typeof s.sender_id === "string" &&
|
||||
s.sender_id === withWhatsAppPrefix(from)),
|
||||
);
|
||||
if (!match || typeof match.sid !== "string") {
|
||||
throw new Error(
|
||||
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
||||
);
|
||||
}
|
||||
return match.sid;
|
||||
} catch (err) {
|
||||
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
||||
if (isVerbose()) {
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-51
@@ -1,79 +1,79 @@
|
||||
export type TwilioRequestOptions = {
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type TwilioSender = { sid: string; sender_id: string };
|
||||
|
||||
export type TwilioRequestResponse = {
|
||||
data?: {
|
||||
senders?: TwilioSender[];
|
||||
};
|
||||
data?: {
|
||||
senders?: TwilioSender[];
|
||||
};
|
||||
};
|
||||
|
||||
export type IncomingNumber = {
|
||||
sid: string;
|
||||
phoneNumber: string;
|
||||
smsUrl?: string;
|
||||
sid: string;
|
||||
phoneNumber: string;
|
||||
smsUrl?: string;
|
||||
};
|
||||
|
||||
export type TwilioChannelsSender = {
|
||||
sid?: string;
|
||||
senderId?: string;
|
||||
sender_id?: string;
|
||||
webhook?: {
|
||||
callback_url?: string;
|
||||
callback_method?: string;
|
||||
fallback_url?: string;
|
||||
fallback_method?: string;
|
||||
};
|
||||
sid?: string;
|
||||
senderId?: string;
|
||||
sender_id?: string;
|
||||
webhook?: {
|
||||
callback_url?: string;
|
||||
callback_method?: string;
|
||||
fallback_url?: string;
|
||||
fallback_method?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelSenderUpdater = {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type IncomingPhoneNumberUpdater = {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type IncomingPhoneNumbersClient = {
|
||||
list: (params: {
|
||||
phoneNumber: string;
|
||||
limit?: number;
|
||||
}) => Promise<IncomingNumber[]>;
|
||||
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||
list: (params: {
|
||||
phoneNumber: string;
|
||||
limit?: number;
|
||||
}) => Promise<IncomingNumber[]>;
|
||||
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
||||
|
||||
export type TwilioSenderListClient = {
|
||||
messaging: {
|
||||
v2: {
|
||||
channelsSenders: {
|
||||
list: (params: {
|
||||
channel: string;
|
||||
pageSize: number;
|
||||
}) => Promise<TwilioChannelsSender[]>;
|
||||
(
|
||||
sid: string,
|
||||
): ChannelSenderUpdater & {
|
||||
fetch: () => Promise<TwilioChannelsSender>;
|
||||
};
|
||||
};
|
||||
};
|
||||
v1: {
|
||||
services: (sid: string) => {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||
messaging: {
|
||||
v2: {
|
||||
channelsSenders: {
|
||||
list: (params: {
|
||||
channel: string;
|
||||
pageSize: number;
|
||||
}) => Promise<TwilioChannelsSender[]>;
|
||||
(
|
||||
sid: string,
|
||||
): ChannelSenderUpdater & {
|
||||
fetch: () => Promise<TwilioChannelsSender>;
|
||||
};
|
||||
};
|
||||
};
|
||||
v1: {
|
||||
services: (sid: string) => {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||
};
|
||||
|
||||
export type TwilioRequester = {
|
||||
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
||||
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
||||
};
|
||||
|
||||
+31
-31
@@ -2,42 +2,42 @@ import { isVerbose, logVerbose, warn } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
type TwilioRequestOptions = {
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type TwilioRequester = {
|
||||
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
||||
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function sendTypingIndicator(
|
||||
client: TwilioRequester,
|
||||
runtime: RuntimeEnv,
|
||||
messageSid?: string,
|
||||
client: TwilioRequester,
|
||||
runtime: RuntimeEnv,
|
||||
messageSid?: string,
|
||||
) {
|
||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||
if (!messageSid) {
|
||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.request({
|
||||
method: "post",
|
||||
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
||||
form: {
|
||||
messageId: messageSid,
|
||||
channel: "whatsapp",
|
||||
},
|
||||
});
|
||||
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
}
|
||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||
if (!messageSid) {
|
||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.request({
|
||||
method: "post",
|
||||
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
||||
form: {
|
||||
messageId: messageSid,
|
||||
channel: "whatsapp",
|
||||
},
|
||||
});
|
||||
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
} from "./update-webhook.js";
|
||||
|
||||
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
||||
|
||||
describe("update-webhook helpers", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
||||
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
||||
});
|
||||
beforeEach(() => {
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
||||
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.entries(envBackup).forEach(([k, v]) => {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.entries(envBackup).forEach(([k, v]) => {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
});
|
||||
});
|
||||
|
||||
it("findIncomingNumberSid returns first match", async () => {
|
||||
const client = {
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
||||
},
|
||||
} as never;
|
||||
const sid = await findIncomingNumberSid(client);
|
||||
expect(sid).toBe("PN1");
|
||||
});
|
||||
it("findIncomingNumberSid returns first match", async () => {
|
||||
const client = {
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
||||
},
|
||||
} as never;
|
||||
const sid = await findIncomingNumberSid(client);
|
||||
expect(sid).toBe("PN1");
|
||||
});
|
||||
|
||||
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
||||
const client = {
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||
},
|
||||
} as never;
|
||||
const sid = await findMessagingServiceSid(client);
|
||||
expect(sid).toBe("MG1");
|
||||
});
|
||||
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
||||
const client = {
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||
},
|
||||
} as never;
|
||||
const sid = await findMessagingServiceSid(client);
|
||||
expect(sid).toBe("MG1");
|
||||
});
|
||||
|
||||
it("setMessagingServiceWebhook updates via service helper", async () => {
|
||||
const update = async (_: unknown) => {};
|
||||
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
||||
const client = {
|
||||
messaging: {
|
||||
v1: {
|
||||
services: () => ({ update, fetch }),
|
||||
},
|
||||
},
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||
},
|
||||
} as never;
|
||||
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
it("setMessagingServiceWebhook updates via service helper", async () => {
|
||||
const update = async (_: unknown) => {};
|
||||
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
||||
const client = {
|
||||
messaging: {
|
||||
v1: {
|
||||
services: () => ({ update, fetch }),
|
||||
},
|
||||
},
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||
},
|
||||
} as never;
|
||||
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+170
-170
@@ -6,193 +6,193 @@ import type { createClient } from "./client.js";
|
||||
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
|
||||
|
||||
export async function findIncomingNumberSid(
|
||||
client: TwilioSenderListClient,
|
||||
client: TwilioSenderListClient,
|
||||
): Promise<string | null> {
|
||||
// Look up incoming phone number SID matching the configured WhatsApp number.
|
||||
try {
|
||||
const env = readEnv();
|
||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||
const list = await client.incomingPhoneNumbers.list({
|
||||
phoneNumber: phone,
|
||||
limit: 1,
|
||||
});
|
||||
return list?.[0]?.sid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Look up incoming phone number SID matching the configured WhatsApp number.
|
||||
try {
|
||||
const env = readEnv();
|
||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||
const list = await client.incomingPhoneNumbers.list({
|
||||
phoneNumber: phone,
|
||||
limit: 1,
|
||||
});
|
||||
return list?.[0]?.sid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findMessagingServiceSid(
|
||||
client: TwilioSenderListClient,
|
||||
client: TwilioSenderListClient,
|
||||
): Promise<string | null> {
|
||||
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||
try {
|
||||
const env = readEnv();
|
||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||
const list = await client.incomingPhoneNumbers.list({
|
||||
phoneNumber: phone,
|
||||
limit: 1,
|
||||
});
|
||||
const msid =
|
||||
(list?.[0] as IncomingNumberWithService | undefined)
|
||||
?.messagingServiceSid ?? null;
|
||||
return msid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||
try {
|
||||
const env = readEnv();
|
||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||
const list = await client.incomingPhoneNumbers.list({
|
||||
phoneNumber: phone,
|
||||
limit: 1,
|
||||
});
|
||||
const msid =
|
||||
(list?.[0] as IncomingNumberWithService | undefined)
|
||||
?.messagingServiceSid ?? null;
|
||||
return msid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMessagingServiceWebhook(
|
||||
client: TwilioSenderListClient,
|
||||
url: string,
|
||||
method: "POST" | "GET",
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
client: TwilioSenderListClient,
|
||||
url: string,
|
||||
method: "POST" | "GET",
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<boolean> {
|
||||
const msid = await findMessagingServiceSid(client);
|
||||
if (!msid) return false;
|
||||
try {
|
||||
await client.messaging.v1.services(msid).update({
|
||||
InboundRequestUrl: url,
|
||||
InboundRequestMethod: method,
|
||||
});
|
||||
const fetched = await client.messaging.v1.services(msid).fetch();
|
||||
const stored = fetched?.inboundRequestUrl;
|
||||
logInfo(
|
||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||
runtime,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const msid = await findMessagingServiceSid(client);
|
||||
if (!msid) return false;
|
||||
try {
|
||||
await client.messaging.v1.services(msid).update({
|
||||
InboundRequestUrl: url,
|
||||
InboundRequestMethod: method,
|
||||
});
|
||||
const fetched = await client.messaging.v1.services(msid).fetch();
|
||||
const stored = fetched?.inboundRequestUrl;
|
||||
logInfo(
|
||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||
runtime,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
||||
export async function updateWebhook(
|
||||
client: ReturnType<typeof createClient>,
|
||||
senderSid: string,
|
||||
url: string,
|
||||
method: "POST" | "GET" = "POST",
|
||||
runtime: RuntimeEnv,
|
||||
client: ReturnType<typeof createClient>,
|
||||
senderSid: string,
|
||||
url: string,
|
||||
method: "POST" | "GET" = "POST",
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
// Point Twilio sender webhook at the provided URL.
|
||||
const requester = client as unknown as TwilioRequester;
|
||||
const clientTyped = client as unknown as TwilioSenderListClient;
|
||||
// Point Twilio sender webhook at the provided URL.
|
||||
const requester = client as unknown as TwilioRequester;
|
||||
const clientTyped = client as unknown as TwilioSenderListClient;
|
||||
|
||||
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
||||
try {
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||
body: {
|
||||
webhook: {
|
||||
callback_url: url,
|
||||
callback_method: method,
|
||||
},
|
||||
},
|
||||
contentType: "application/json",
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
if (storedUrl) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
||||
try {
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||
body: {
|
||||
webhook: {
|
||||
callback_url: url,
|
||||
callback_method: method,
|
||||
},
|
||||
},
|
||||
contentType: "application/json",
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
if (storedUrl) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 1b) Form-encoded fallback for older Twilio stacks
|
||||
try {
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||
form: {
|
||||
"Webhook.CallbackUrl": url,
|
||||
"Webhook.CallbackMethod": method,
|
||||
},
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
if (storedUrl) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
// 1b) Form-encoded fallback for older Twilio stacks
|
||||
try {
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||
form: {
|
||||
"Webhook.CallbackUrl": url,
|
||||
"Webhook.CallbackMethod": method,
|
||||
},
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
if (storedUrl) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 2) SDK helper fallback (if supported by this client)
|
||||
try {
|
||||
if (clientTyped.messaging?.v2?.channelsSenders) {
|
||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||
callbackUrl: url,
|
||||
callbackMethod: method,
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
logInfo(
|
||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||
runtime,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
// 2) SDK helper fallback (if supported by this client)
|
||||
try {
|
||||
if (clientTyped.messaging?.v2?.channelsSenders) {
|
||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||
callbackUrl: url,
|
||||
callbackMethod: method,
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
logInfo(
|
||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||
runtime,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 3) Incoming phone number fallback (works for many WA senders)
|
||||
try {
|
||||
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||
if (phoneSid) {
|
||||
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
||||
smsUrl: url,
|
||||
smsMethod: method,
|
||||
});
|
||||
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
// 3) Incoming phone number fallback (works for many WA senders)
|
||||
try {
|
||||
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||
if (phoneSid) {
|
||||
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
||||
smsUrl: url,
|
||||
smsMethod: method,
|
||||
});
|
||||
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
runtime.error(
|
||||
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
||||
);
|
||||
runtime.error(
|
||||
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
||||
);
|
||||
}
|
||||
|
||||
+25
-25
@@ -2,36 +2,36 @@ import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
type TwilioApiError = {
|
||||
code?: number | string;
|
||||
status?: number | string;
|
||||
message?: string;
|
||||
moreInfo?: string;
|
||||
response?: { body?: unknown };
|
||||
code?: number | string;
|
||||
status?: number | string;
|
||||
message?: string;
|
||||
moreInfo?: string;
|
||||
response?: { body?: unknown };
|
||||
};
|
||||
|
||||
export function formatTwilioError(err: unknown): string {
|
||||
// Normalize Twilio error objects into a single readable string.
|
||||
const e = err as TwilioApiError;
|
||||
const pieces = [];
|
||||
if (e.code != null) pieces.push(`code ${e.code}`);
|
||||
if (e.status != null) pieces.push(`status ${e.status}`);
|
||||
if (e.message) pieces.push(e.message);
|
||||
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
||||
return pieces.length ? pieces.join(" | ") : String(err);
|
||||
// Normalize Twilio error objects into a single readable string.
|
||||
const e = err as TwilioApiError;
|
||||
const pieces = [];
|
||||
if (e.code != null) pieces.push(`code ${e.code}`);
|
||||
if (e.status != null) pieces.push(`status ${e.status}`);
|
||||
if (e.message) pieces.push(e.message);
|
||||
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
||||
return pieces.length ? pieces.join(" | ") : String(err);
|
||||
}
|
||||
|
||||
export function logTwilioSendError(
|
||||
err: unknown,
|
||||
destination?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
err: unknown,
|
||||
destination?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Friendly error logger for send failures, including response body when present.
|
||||
const prefix = destination ? `to ${destination}: ` : "";
|
||||
runtime.error(
|
||||
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
||||
);
|
||||
const body = (err as TwilioApiError)?.response?.body;
|
||||
if (body) {
|
||||
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
||||
}
|
||||
// Friendly error logger for send failures, including response body when present.
|
||||
const prefix = destination ? `to ${destination}: ` : "";
|
||||
runtime.error(
|
||||
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
||||
);
|
||||
const body = (err as TwilioApiError)?.response?.body;
|
||||
if (body) {
|
||||
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
+123
-123
@@ -5,7 +5,7 @@ import express, { type Request, type Response } from "express";
|
||||
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
|
||||
import { type EnvConfig, readEnv } from "../env.js";
|
||||
import { danger, success } from "../globals.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import * as mediaHost from "../media/host.js";
|
||||
import { attachMediaRoutes } from "../media/server.js";
|
||||
import { saveMediaSource } from "../media/store.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
@@ -16,143 +16,143 @@ import { logTwilioSendError } from "./utils.js";
|
||||
|
||||
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
||||
export async function startWebhook(
|
||||
port: number,
|
||||
path = "/webhook/whatsapp",
|
||||
autoReply: string | undefined,
|
||||
verbose: boolean,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
port: number,
|
||||
path = "/webhook/whatsapp",
|
||||
autoReply: string | undefined,
|
||||
verbose: boolean,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<Server> {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const env = readEnv(runtime);
|
||||
const app = express();
|
||||
const normalizedPath = normalizePath(path);
|
||||
const env = readEnv(runtime);
|
||||
const app = express();
|
||||
|
||||
attachMediaRoutes(app, undefined, runtime);
|
||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||
next();
|
||||
});
|
||||
attachMediaRoutes(app, undefined, runtime);
|
||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||
next();
|
||||
});
|
||||
|
||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||
runtime.log(`
|
||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||
runtime.log(`
|
||||
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
||||
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||
|
||||
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||
let mediaPath: string | undefined;
|
||||
let mediaUrlInbound: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||
mediaType =
|
||||
typeof req.body?.MediaContentType0 === "string"
|
||||
? (req.body.MediaContentType0 as string)
|
||||
: undefined;
|
||||
try {
|
||||
const creds = buildTwilioBasicAuth(env);
|
||||
const saved = await saveMediaSource(
|
||||
mediaUrlInbound,
|
||||
{
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||
} catch (err) {
|
||||
runtime.error(
|
||||
danger(`Failed to download inbound media: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||
let mediaPath: string | undefined;
|
||||
let mediaUrlInbound: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||
mediaType =
|
||||
typeof req.body?.MediaContentType0 === "string"
|
||||
? (req.body.MediaContentType0 as string)
|
||||
: undefined;
|
||||
try {
|
||||
const creds = buildTwilioBasicAuth(env);
|
||||
const saved = await saveMediaSource(
|
||||
mediaUrlInbound,
|
||||
{
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||
} catch (err) {
|
||||
runtime.error(
|
||||
danger(`Failed to download inbound media: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = createClient(env);
|
||||
let replyResult: ReplyPayload | undefined =
|
||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||
if (!replyResult) {
|
||||
replyResult = await getReplyFromConfig(
|
||||
{
|
||||
Body,
|
||||
From,
|
||||
To,
|
||||
MessageSid,
|
||||
MediaPath: mediaPath,
|
||||
MediaUrl: mediaUrlInbound,
|
||||
MediaType: mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||
},
|
||||
);
|
||||
}
|
||||
const client = createClient(env);
|
||||
let replyResult: ReplyPayload | undefined =
|
||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||
if (!replyResult) {
|
||||
replyResult = await getReplyFromConfig(
|
||||
{
|
||||
Body,
|
||||
From,
|
||||
To,
|
||||
MessageSid,
|
||||
MediaPath: mediaPath,
|
||||
MediaUrl: mediaUrlInbound,
|
||||
MediaType: mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: To,
|
||||
to: From,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (verbose)
|
||||
runtime.log(
|
||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||
);
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, From ?? undefined, runtime);
|
||||
}
|
||||
}
|
||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await mediaHost.ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: To,
|
||||
to: From,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (verbose)
|
||||
runtime.log(
|
||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||
);
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, From ?? undefined, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
// Respond 200 OK to Twilio.
|
||||
res.type("text/xml").send("<Response></Response>");
|
||||
});
|
||||
// Respond 200 OK to Twilio.
|
||||
res.type("text/xml").send("<Response></Response>");
|
||||
});
|
||||
|
||||
app.use((_req, res) => {
|
||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||
res.status(404).send("warelay webhook: not found");
|
||||
});
|
||||
app.use((_req, res) => {
|
||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||
res.status(404).send("warelay webhook: not found");
|
||||
});
|
||||
|
||||
// Start server and resolve once listening; reject on bind error.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
// Start server and resolve once listening; reject on bind error.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
|
||||
const onListening = () => {
|
||||
cleanup();
|
||||
runtime.log(
|
||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||
);
|
||||
resolve(server);
|
||||
};
|
||||
const onListening = () => {
|
||||
cleanup();
|
||||
runtime.log(
|
||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||
);
|
||||
resolve(server);
|
||||
};
|
||||
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
server.off("listening", onListening);
|
||||
server.off("error", onError);
|
||||
};
|
||||
const cleanup = () => {
|
||||
server.off("listening", onListening);
|
||||
server.off("error", onError);
|
||||
};
|
||||
|
||||
server.once("listening", onListening);
|
||||
server.once("error", onError);
|
||||
});
|
||||
server.once("listening", onListening);
|
||||
server.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTwilioBasicAuth(env: EnvConfig) {
|
||||
if ("authToken" in env.auth) {
|
||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
||||
"base64",
|
||||
);
|
||||
if ("authToken" in env.auth) {
|
||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
|
||||
+61
-43
@@ -3,67 +3,85 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertProvider,
|
||||
ensureDir,
|
||||
normalizeE164,
|
||||
normalizePath,
|
||||
sleep,
|
||||
toWhatsappJid,
|
||||
withWhatsAppPrefix,
|
||||
assertProvider,
|
||||
CONFIG_DIR,
|
||||
ensureDir,
|
||||
jidToE164,
|
||||
normalizeE164,
|
||||
normalizePath,
|
||||
sleep,
|
||||
toWhatsappJid,
|
||||
withWhatsAppPrefix,
|
||||
} from "./utils.js";
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("adds leading slash when missing", () => {
|
||||
expect(normalizePath("foo")).toBe("/foo");
|
||||
});
|
||||
it("adds leading slash when missing", () => {
|
||||
expect(normalizePath("foo")).toBe("/foo");
|
||||
});
|
||||
|
||||
it("keeps existing slash", () => {
|
||||
expect(normalizePath("/bar")).toBe("/bar");
|
||||
});
|
||||
it("keeps existing slash", () => {
|
||||
expect(normalizePath("/bar")).toBe("/bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withWhatsAppPrefix", () => {
|
||||
it("adds whatsapp prefix", () => {
|
||||
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
|
||||
});
|
||||
it("adds whatsapp prefix", () => {
|
||||
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
|
||||
});
|
||||
|
||||
it("leaves prefixed intact", () => {
|
||||
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
|
||||
});
|
||||
it("leaves prefixed intact", () => {
|
||||
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureDir", () => {
|
||||
it("creates nested directory", async () => {
|
||||
const tmp = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "warelay-test-"),
|
||||
);
|
||||
const target = path.join(tmp, "nested", "dir");
|
||||
await ensureDir(target);
|
||||
expect(fs.existsSync(target)).toBe(true);
|
||||
});
|
||||
it("creates nested directory", async () => {
|
||||
const tmp = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "warelay-test-"),
|
||||
);
|
||||
const target = path.join(tmp, "nested", "dir");
|
||||
await ensureDir(target);
|
||||
expect(fs.existsSync(target)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sleep", () => {
|
||||
it("resolves after delay using fake timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = sleep(1000);
|
||||
vi.advanceTimersByTime(1000);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("resolves after delay using fake timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = sleep(1000);
|
||||
vi.advanceTimersByTime(1000);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertProvider", () => {
|
||||
it("throws for invalid provider", () => {
|
||||
expect(() => assertProvider("bad" as string)).toThrow();
|
||||
});
|
||||
it("throws for invalid provider", () => {
|
||||
expect(() => assertProvider("bad" as string)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeE164 & toWhatsappJid", () => {
|
||||
it("strips formatting and prefixes", () => {
|
||||
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
||||
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
|
||||
"5551234567@s.whatsapp.net",
|
||||
);
|
||||
});
|
||||
it("strips formatting and prefixes", () => {
|
||||
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
||||
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
|
||||
"5551234567@s.whatsapp.net",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jidToE164", () => {
|
||||
it("maps @lid using reverse mapping file", () => {
|
||||
const mappingPath = `${CONFIG_DIR}/credentials/lid-mapping-123_reverse.json`;
|
||||
const original = fs.readFileSync;
|
||||
const spy = vi
|
||||
.spyOn(fs, "readFileSync")
|
||||
// biome-ignore lint/suspicious/noExplicitAny: forwarding to native signature
|
||||
.mockImplementation((path: any, encoding?: any) => {
|
||||
if (path === mappingPath) return `"5551234"`;
|
||||
return original(path, encoding);
|
||||
});
|
||||
expect(jidToE164("123@lid")).toBe("+5551234");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+43
-20
@@ -1,50 +1,73 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { isVerbose, logVerbose } from "./globals.js";
|
||||
|
||||
export async function ensureDir(dir: string) {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export type Provider = "twilio" | "web";
|
||||
|
||||
export function assertProvider(input: string): asserts input is Provider {
|
||||
if (input !== "twilio" && input !== "web") {
|
||||
throw new Error("Provider must be 'twilio' or 'web'");
|
||||
}
|
||||
if (input !== "twilio" && input !== "web") {
|
||||
throw new Error("Provider must be 'twilio' or 'web'");
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
if (!p.startsWith("/")) return `/${p}`;
|
||||
return p;
|
||||
if (!p.startsWith("/")) return `/${p}`;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function withWhatsAppPrefix(number: string): string {
|
||||
return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`;
|
||||
return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`;
|
||||
}
|
||||
|
||||
export function normalizeE164(number: string): string {
|
||||
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
|
||||
const digits = withoutPrefix.replace(/[^\d+]/g, "");
|
||||
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
|
||||
return `+${digits}`;
|
||||
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
|
||||
const digits = withoutPrefix.replace(/[^\d+]/g, "");
|
||||
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
export function toWhatsappJid(number: string): string {
|
||||
const e164 = normalizeE164(number);
|
||||
const digits = e164.replace(/\D/g, "");
|
||||
return `${digits}@s.whatsapp.net`;
|
||||
const e164 = normalizeE164(number);
|
||||
const digits = e164.replace(/\D/g, "");
|
||||
return `${digits}@s.whatsapp.net`;
|
||||
}
|
||||
|
||||
export function jidToE164(jid: string): string | null {
|
||||
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
|
||||
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
|
||||
if (!match) return null;
|
||||
const digits = match[1];
|
||||
return `+${digits}`;
|
||||
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
|
||||
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
|
||||
if (match) {
|
||||
const digits = match[1];
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
// Support @lid format (WhatsApp Linked ID) - look up reverse mapping
|
||||
const lidMatch = jid.match(/^(\d+)(?::\d+)?@lid$/);
|
||||
if (lidMatch) {
|
||||
const lid = lidMatch[1];
|
||||
try {
|
||||
const mappingPath = `${CONFIG_DIR}/credentials/lid-mapping-${lid}_reverse.json`;
|
||||
const data = fs.readFileSync(mappingPath, "utf8");
|
||||
const phone = JSON.parse(data);
|
||||
if (phone) return `+${phone}`;
|
||||
} catch {
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`LID mapping not found for ${lid}; skipping inbound message`,
|
||||
);
|
||||
}
|
||||
// Mapping not found, fall through
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const CONFIG_DIR = `${os.homedir()}/.warelay`;
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WarelayConfig } from "../config/config.js";
|
||||
import { resolveStorePath } from "../config/sessions.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import {
|
||||
HEARTBEAT_PROMPT,
|
||||
HEARTBEAT_TOKEN,
|
||||
monitorWebProvider,
|
||||
resolveHeartbeatRecipients,
|
||||
resolveReplyHeartbeatMinutes,
|
||||
runWebHeartbeatOnce,
|
||||
stripHeartbeatToken,
|
||||
} from "./auto-reply.js";
|
||||
import type { sendMessageWeb } from "./outbound.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
describe("heartbeat helpers", () => {
|
||||
it("strips heartbeat token and skips when only token", () => {
|
||||
expect(stripHeartbeatToken(undefined)).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken(" ")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps content and removes token when mixed", () => {
|
||||
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
|
||||
shouldSkip: false,
|
||||
text: "ALERT",
|
||||
});
|
||||
expect(stripHeartbeatToken(`hello`)).toEqual({
|
||||
shouldSkip: false,
|
||||
text: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves heartbeat minutes with default and overrides", () => {
|
||||
const cfgBase: WarelayConfig = {
|
||||
inbound: {
|
||||
reply: { mode: "command" as const },
|
||||
},
|
||||
};
|
||||
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
|
||||
expect(
|
||||
resolveReplyHeartbeatMinutes({
|
||||
inbound: { reply: { mode: "command", heartbeatMinutes: 5 } },
|
||||
}),
|
||||
).toBe(5);
|
||||
expect(
|
||||
resolveReplyHeartbeatMinutes({
|
||||
inbound: { reply: { mode: "command", heartbeatMinutes: 0 } },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7);
|
||||
expect(
|
||||
resolveReplyHeartbeatMinutes({
|
||||
inbound: { reply: { mode: "text" } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHeartbeatRecipients", () => {
|
||||
const makeStore = async (entries: Record<string, { updatedAt: number }>) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "warelay-heartbeat-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
return {
|
||||
storePath,
|
||||
cleanup: async () => fs.rm(dir, { recursive: true, force: true }),
|
||||
};
|
||||
};
|
||||
|
||||
it("returns the sole session recipient", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeStore({ "+1000": { updatedAt: now } });
|
||||
const cfg: WarelayConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
expect(result.source).toBe("session-single");
|
||||
expect(result.recipients).toEqual(["+1000"]);
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("surfaces ambiguity when multiple sessions exist", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeStore({
|
||||
"+1000": { updatedAt: now },
|
||||
"+2000": { updatedAt: now - 10 },
|
||||
});
|
||||
const cfg: WarelayConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
expect(result.source).toBe("session-ambiguous");
|
||||
expect(result.recipients).toEqual(["+1000", "+2000"]);
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("filters wildcard allowFrom when no sessions exist", async () => {
|
||||
const store = await makeStore({});
|
||||
const cfg: WarelayConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["*"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
expect(result.recipients).toHaveLength(0);
|
||||
expect(result.source).toBe("allowFrom");
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("merges sessions and allowFrom when --all is set", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeStore({ "+1000": { updatedAt: now } });
|
||||
const cfg: WarelayConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: { mode: "command", session: { store: store.storePath } },
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg, { all: true });
|
||||
expect(result.source).toBe("all");
|
||||
expect(result.recipients.sort()).toEqual(["+1000", "+1999"].sort());
|
||||
await store.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWebHeartbeatOnce", () => {
|
||||
it("skips when heartbeat token returned", async () => {
|
||||
const sender: typeof sendMessageWeb = vi.fn();
|
||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||
setLoadConfigMock({
|
||||
inbound: { allowFrom: ["+1555"], reply: { mode: "command" } },
|
||||
});
|
||||
await runWebHeartbeatOnce({
|
||||
to: "+1555",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
});
|
||||
expect(resolver).toHaveBeenCalled();
|
||||
expect(sender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends when alert text present", async () => {
|
||||
const sender: typeof sendMessageWeb = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
||||
setLoadConfigMock({
|
||||
inbound: { allowFrom: ["+1555"], reply: { mode: "command" } },
|
||||
});
|
||||
await runWebHeartbeatOnce({
|
||||
to: "+1555",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
});
|
||||
expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false });
|
||||
});
|
||||
|
||||
it("falls back to most recent session when no to is provided", async () => {
|
||||
const sender: typeof sendMessageWeb = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
||||
// Seed session store
|
||||
const now = Date.now();
|
||||
const store = {
|
||||
"+1222": { sessionId: "s1", updatedAt: now - 1000 },
|
||||
"+1333": { sessionId: "s2", updatedAt: now },
|
||||
};
|
||||
const storePath = resolveStorePath();
|
||||
await fs.mkdir(resolveStorePath().replace("sessions.json", ""), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(storePath, JSON.stringify(store));
|
||||
setLoadConfigMock({
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: { mode: "command", session: {} },
|
||||
},
|
||||
});
|
||||
await runWebHeartbeatOnce({
|
||||
to: "+1999",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
});
|
||||
expect(sender).toHaveBeenCalledWith("+1999", "ALERT", { verbose: false });
|
||||
});
|
||||
|
||||
it("does not refresh updatedAt when heartbeat is skipped", async () => {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "warelay-heartbeat-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const now = Date.now();
|
||||
const originalUpdated = now - 30 * 60 * 1000;
|
||||
const store = {
|
||||
"+1555": { sessionId: "sess1", updatedAt: originalUpdated },
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify(store));
|
||||
|
||||
const sender: typeof sendMessageWeb = vi.fn();
|
||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||
setLoadConfigMock({
|
||||
inbound: {
|
||||
allowFrom: ["+1555"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: {
|
||||
store: storePath,
|
||||
idleMinutes: 60,
|
||||
heartbeatIdleMinutes: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runWebHeartbeatOnce({
|
||||
to: "+1555",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
});
|
||||
|
||||
const after = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(after["+1555"].updatedAt).toBe(originalUpdated);
|
||||
expect(sender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("heartbeat reuses existing session id when last inbound is present", async () => {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "warelay-heartbeat-session-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const sessionId = "sess-keep";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"+4367": { sessionId, updatedAt: Date.now(), systemSent: false },
|
||||
}),
|
||||
);
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
inbound: {
|
||||
allowFrom: ["+4367"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
heartbeatMinutes: 0.001,
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN });
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
|
||||
const cfg: WarelayConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+4367"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runWebHeartbeatOnce({
|
||||
cfg,
|
||||
to: "+4367",
|
||||
verbose: false,
|
||||
replyResolver,
|
||||
runtime,
|
||||
});
|
||||
|
||||
const heartbeatCall = replyResolver.mock.calls.find(
|
||||
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
|
||||
);
|
||||
expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("heartbeat honors session-id override and seeds store", async () => {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "warelay-heartbeat-override-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify({}));
|
||||
|
||||
const sessionId = "override-123";
|
||||
setLoadConfigMock(() => ({
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||
const cfg: WarelayConfig = {
|
||||
inbound: {
|
||||
allowFrom: ["+1999"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
},
|
||||
},
|
||||
};
|
||||
await runWebHeartbeatOnce({
|
||||
cfg,
|
||||
to: "+1999",
|
||||
verbose: false,
|
||||
replyResolver: resolver,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
const heartbeatCall = resolver.mock.calls.find(
|
||||
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
|
||||
);
|
||||
expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId);
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const stored = raw ? JSON.parse(raw) : {};
|
||||
expect(stored["+1999"]?.sessionId).toBe(sessionId);
|
||||
expect(stored["+1999"]?.updatedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reconnects after a connection close", async () => {
|
||||
const closeResolvers: Array<() => void> = [];
|
||||
const sleep = vi.fn(async () => {});
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
let _resolve!: () => void;
|
||||
const onClose = new Promise<void>((res) => {
|
||||
_resolve = res;
|
||||
closeResolvers.push(res);
|
||||
});
|
||||
return { close: vi.fn(), onClose };
|
||||
});
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = monitorWebProvider(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
controller.signal,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
|
||||
sleep,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeResolvers[0]?.();
|
||||
const waitForSecondCall = async () => {
|
||||
const started = Date.now();
|
||||
while (
|
||||
listenerFactory.mock.calls.length < 2 &&
|
||||
Date.now() - started < 200
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
await waitForSecondCall();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Retry 1"),
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
closeResolvers[1]?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await run;
|
||||
});
|
||||
|
||||
it("stops after hitting max reconnect attempts", async () => {
|
||||
const closeResolvers: Array<() => void> = [];
|
||||
const sleep = vi.fn(async () => {});
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
const onClose = new Promise<void>((res) => closeResolvers.push(res));
|
||||
return { close: vi.fn(), onClose };
|
||||
});
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const run = monitorWebProvider(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
undefined,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 2, factor: 1.1 },
|
||||
sleep,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeResolvers.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
|
||||
closeResolvers.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
await run;
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Reached max retries"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to text when media send fails", async () => {
|
||||
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/img.png",
|
||||
});
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const smallPng = await sharp({
|
||||
create: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
channels: 3,
|
||||
background: { r: 0, g: 255, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
smallPng.buffer.slice(
|
||||
smallPng.byteOffset,
|
||||
smallPng.byteOffset + smallPng.byteLength,
|
||||
),
|
||||
headers: { get: () => "image/png" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith("hi");
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it("compresses media over 5MB and still sends it", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/big.png",
|
||||
});
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const bigPng = await sharp({
|
||||
create: {
|
||||
width: 3200,
|
||||
height: 3200,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
expect(bigPng.length).toBeGreaterThan(5 * 1024 * 1024);
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
bigPng.buffer.slice(
|
||||
bigPng.byteOffset,
|
||||
bigPng.byteOffset + bigPng.byteLength,
|
||||
),
|
||||
headers: { get: () => "image/png" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMedia.mock.calls[0][0] as {
|
||||
image: Buffer;
|
||||
caption?: string;
|
||||
mimetype?: string;
|
||||
};
|
||||
expect(payload.image.length).toBeLessThanOrEqual(5 * 1024 * 1024);
|
||||
expect(payload.mimetype).toBe("image/jpeg");
|
||||
// Should not fall back to separate text reply because caption is used.
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it(
|
||||
"compresses common formats to jpeg under the cap",
|
||||
{ timeout: 15_000 },
|
||||
async () => {
|
||||
const formats = [
|
||||
{
|
||||
name: "png",
|
||||
mime: "image/png",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer(),
|
||||
},
|
||||
{
|
||||
name: "jpeg",
|
||||
mime: "image/jpeg",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.jpeg({ quality: 100, chromaSubsampling: "4:4:4" })
|
||||
.toBuffer(),
|
||||
},
|
||||
{
|
||||
name: "webp",
|
||||
mime: "image/webp",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.webp({ quality: 100 })
|
||||
.toBuffer(),
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const fmt of formats) {
|
||||
// Force a small cap to ensure compression is exercised for every format.
|
||||
setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } }));
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: `https://example.com/big.${fmt.name}`,
|
||||
});
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const width = 1200;
|
||||
const height = 1200;
|
||||
const raw = crypto.randomBytes(width * height * 3);
|
||||
const big = await fmt.make(raw, { width, height });
|
||||
expect(big.length).toBeGreaterThan(1 * 1024 * 1024);
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
big.buffer.slice(big.byteOffset, big.byteOffset + big.byteLength),
|
||||
headers: { get: () => fmt.mime },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: `msg-${fmt.name}`,
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMedia.mock.calls[0][0] as {
|
||||
image: Buffer;
|
||||
mimetype?: string;
|
||||
};
|
||||
expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024);
|
||||
expect(payload.mimetype).toBe("image/jpeg");
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
resetLoadConfigMock();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("honors mediaMaxMb from config", async () => {
|
||||
setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } }));
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/big.png",
|
||||
});
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const bigPng = await sharp({
|
||||
create: {
|
||||
width: 2600,
|
||||
height: 2600,
|
||||
channels: 3,
|
||||
background: { r: 0, g: 0, b: 255 },
|
||||
},
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024);
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
bigPng.buffer.slice(
|
||||
bigPng.byteOffset,
|
||||
bigPng.byteOffset + bigPng.byteLength,
|
||||
),
|
||||
headers: { get: () => "image/png" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMedia.mock.calls[0][0] as {
|
||||
image: Buffer;
|
||||
caption?: string;
|
||||
mimetype?: string;
|
||||
};
|
||||
expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024);
|
||||
expect(payload.mimetype).toBe("image/jpeg");
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to text when media is unsupported", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/file.pdf",
|
||||
});
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer,
|
||||
headers: { get: () => "application/pdf" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg-pdf",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMedia.mock.calls[0][0] as {
|
||||
document?: Buffer;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
expect(payload.document).toBeInstanceOf(Buffer);
|
||||
expect(payload.fileName).toBe("file.pdf");
|
||||
expect(payload.caption).toBe("hi");
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it("emits heartbeat logs with connection metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
const logPath = `/tmp/warelay-heartbeat-${crypto.randomUUID()}.log`;
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
const onClose = new Promise<void>(() => {
|
||||
// never resolves; abort will short-circuit
|
||||
});
|
||||
return { close: vi.fn(), onClose };
|
||||
});
|
||||
|
||||
const run = monitorWebProvider(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
controller.signal,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 1, factor: 1.1 },
|
||||
},
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
controller.abort();
|
||||
await vi.runAllTimersAsync();
|
||||
await run.catch(() => {});
|
||||
|
||||
const content = await fs.readFile(logPath, "utf-8");
|
||||
expect(content).toContain('"module":"web-heartbeat"');
|
||||
expect(content).toMatch(/connectionId/);
|
||||
expect(content).toMatch(/messagesHandled/);
|
||||
});
|
||||
|
||||
it("logs outbound replies to file", async () => {
|
||||
const logPath = `/tmp/warelay-log-test-${crypto.randomUUID()}.log`;
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
const content = await fs.readFile(logPath, "utf-8");
|
||||
expect(content).toContain('"module":"web-auto-reply"');
|
||||
expect(content).toContain('"text":"auto"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,861 @@
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { waitForever } from "../cli/wait.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { danger, isVerbose, logVerbose, success } from "../globals.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { monitorWebInbox } from "./inbound.js";
|
||||
import { loadWebMedia } from "./media.js";
|
||||
import { sendMessageWeb } from "./outbound.js";
|
||||
import {
|
||||
computeBackoff,
|
||||
newConnectionId,
|
||||
type ReconnectPolicy,
|
||||
resolveHeartbeatSeconds,
|
||||
resolveReconnectPolicy,
|
||||
sleepWithAbort,
|
||||
} from "./reconnect.js";
|
||||
import { getWebAuthAgeMs } from "./session.js";
|
||||
|
||||
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
||||
type WebInboundMsg = Parameters<
|
||||
typeof monitorWebInbox
|
||||
>[0]["onMessage"] extends (msg: infer M) => unknown
|
||||
? M
|
||||
: never;
|
||||
|
||||
export type WebMonitorTuning = {
|
||||
reconnect?: Partial<ReconnectPolicy>;
|
||||
heartbeatSeconds?: number;
|
||||
replyHeartbeatMinutes?: number;
|
||||
replyHeartbeatNow?: boolean;
|
||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) =>
|
||||
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||
|
||||
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
|
||||
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
||||
export const HEARTBEAT_PROMPT = "HEARTBEAT ultrathink";
|
||||
|
||||
export function resolveReplyHeartbeatMinutes(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
overrideMinutes?: number,
|
||||
) {
|
||||
const raw = overrideMinutes ?? cfg.inbound?.reply?.heartbeatMinutes;
|
||||
if (raw === 0) return null;
|
||||
if (typeof raw === "number" && raw > 0) return raw;
|
||||
return cfg.inbound?.reply?.mode === "command"
|
||||
? DEFAULT_REPLY_HEARTBEAT_MINUTES
|
||||
: null;
|
||||
}
|
||||
|
||||
export function stripHeartbeatToken(raw?: string) {
|
||||
if (!raw) return { shouldSkip: true, text: "" };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { shouldSkip: true, text: "" };
|
||||
if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" };
|
||||
const withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim();
|
||||
return {
|
||||
shouldSkip: withoutToken.length === 0,
|
||||
text: withoutToken || trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWebHeartbeatOnce(opts: {
|
||||
cfg?: ReturnType<typeof loadConfig>;
|
||||
to: string;
|
||||
verbose?: boolean;
|
||||
replyResolver?: typeof getReplyFromConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
sender?: typeof sendMessageWeb;
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const { cfg: cfgOverride, to, verbose = false, sessionId } = opts;
|
||||
const _runtime = opts.runtime ?? defaultRuntime;
|
||||
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
||||
const sender = opts.sender ?? sendMessageWeb;
|
||||
const runId = newConnectionId();
|
||||
const heartbeatLogger = getChildLogger({
|
||||
module: "web-heartbeat",
|
||||
runId,
|
||||
to,
|
||||
});
|
||||
|
||||
const cfg = cfgOverride ?? loadConfig();
|
||||
if (sessionId) {
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
store[to] = {
|
||||
...(store[to] ?? {}),
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
const sessionSnapshot = getSessionSnapshot(cfg, to, true);
|
||||
if (verbose) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
to,
|
||||
sessionKey: sessionSnapshot.key,
|
||||
sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null,
|
||||
sessionFresh: sessionSnapshot.fresh,
|
||||
idleMinutes: sessionSnapshot.idleMinutes,
|
||||
},
|
||||
"heartbeat session snapshot",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const replyResult = await replyResolver(
|
||||
{
|
||||
Body: HEARTBEAT_PROMPT,
|
||||
From: to,
|
||||
To: to,
|
||||
MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
if (
|
||||
!replyResult ||
|
||||
(!replyResult.text &&
|
||||
!replyResult.mediaUrl &&
|
||||
!replyResult.mediaUrls?.length)
|
||||
) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
to,
|
||||
reason: "empty-reply",
|
||||
sessionId: sessionSnapshot.entry?.sessionId ?? null,
|
||||
},
|
||||
"heartbeat skipped",
|
||||
);
|
||||
if (verbose) console.log(success("heartbeat: ok (empty reply)"));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMedia = Boolean(
|
||||
replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const stripped = stripHeartbeatToken(replyResult.text);
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
|
||||
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
heartbeatLogger.info(
|
||||
{ to, reason: "heartbeat-token", rawLength: replyResult.text?.length },
|
||||
"heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: ok (HEARTBEAT_OK)"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMedia) {
|
||||
heartbeatLogger.warn(
|
||||
{ to },
|
||||
"heartbeat reply contained media; sending text only",
|
||||
);
|
||||
}
|
||||
|
||||
const finalText = stripped.text || replyResult.text || "";
|
||||
const sendResult = await sender(to, finalText, { verbose });
|
||||
heartbeatLogger.info(
|
||||
{ to, messageId: sendResult.messageId, chars: finalText.length },
|
||||
"heartbeat sent",
|
||||
);
|
||||
console.log(success(`heartbeat: alert sent to ${to}`));
|
||||
} catch (err) {
|
||||
heartbeatLogger.warn({ to, error: String(err) }, "heartbeat failed");
|
||||
console.log(danger(`heartbeat: failed - ${String(err)}`));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const candidates = Object.entries(store).filter(([key]) => key !== "global");
|
||||
if (candidates.length === 0) {
|
||||
const allowFrom =
|
||||
Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0
|
||||
? cfg.inbound.allowFrom.filter((v) => v !== "*")
|
||||
: [];
|
||||
if (allowFrom.length === 0) return null;
|
||||
return allowFrom[0] ? normalizeE164(allowFrom[0]) : null;
|
||||
}
|
||||
const mostRecent = candidates.sort(
|
||||
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
|
||||
)[0];
|
||||
return mostRecent ? normalizeE164(mostRecent[0]) : null;
|
||||
}
|
||||
|
||||
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
if (scope === "global") return [];
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
return Object.entries(store)
|
||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||
.map(([key, entry]) => ({
|
||||
to: normalizeE164(key),
|
||||
updatedAt: entry?.updatedAt ?? 0,
|
||||
}))
|
||||
.filter(({ to }) => Boolean(to))
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
export function resolveHeartbeatRecipients(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
opts: { to?: string; all?: boolean } = {},
|
||||
) {
|
||||
if (opts.to) return { recipients: [normalizeE164(opts.to)], source: "flag" };
|
||||
|
||||
const sessionRecipients = getSessionRecipients(cfg);
|
||||
const allowFrom =
|
||||
Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0
|
||||
? cfg.inbound.allowFrom.filter((v) => v !== "*").map(normalizeE164)
|
||||
: [];
|
||||
|
||||
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
|
||||
|
||||
if (opts.all) {
|
||||
const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]);
|
||||
return { recipients: all, source: "all" as const };
|
||||
}
|
||||
|
||||
if (sessionRecipients.length === 1) {
|
||||
return { recipients: [sessionRecipients[0].to], source: "session-single" };
|
||||
}
|
||||
if (sessionRecipients.length > 1) {
|
||||
return {
|
||||
recipients: sessionRecipients.map((s) => s.to),
|
||||
source: "session-ambiguous" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return { recipients: allowFrom, source: "allowFrom" as const };
|
||||
}
|
||||
|
||||
function getSessionSnapshot(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
from: string,
|
||||
isHeartbeat = false,
|
||||
) {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
const key = deriveSessionKey(scope, { From: from, To: "", Body: "" });
|
||||
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
||||
const entry = store[key];
|
||||
const idleMinutes = Math.max(
|
||||
(isHeartbeat
|
||||
? (sessionCfg?.heartbeatIdleMinutes ?? sessionCfg?.idleMinutes)
|
||||
: sessionCfg?.idleMinutes) ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const fresh = !!(
|
||||
entry && Date.now() - entry.updatedAt <= idleMinutes * 60_000
|
||||
);
|
||||
return { key, entry, fresh, idleMinutes };
|
||||
}
|
||||
|
||||
async function deliverWebReply(params: {
|
||||
replyResult: ReplyPayload;
|
||||
msg: WebInboundMsg;
|
||||
maxMediaBytes: number;
|
||||
replyLogger: ReturnType<typeof getChildLogger>;
|
||||
runtime: RuntimeEnv;
|
||||
connectionId?: string;
|
||||
skipLog?: boolean;
|
||||
}) {
|
||||
const {
|
||||
replyResult,
|
||||
msg,
|
||||
maxMediaBytes,
|
||||
replyLogger,
|
||||
runtime,
|
||||
connectionId,
|
||||
skipLog,
|
||||
} = params;
|
||||
const replyStarted = Date.now();
|
||||
const mediaList = replyResult.mediaUrls?.length
|
||||
? replyResult.mediaUrls
|
||||
: replyResult.mediaUrl
|
||||
? [replyResult.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (mediaList.length === 0 && replyResult.text) {
|
||||
await msg.reply(replyResult.text || "");
|
||||
if (!skipLog) {
|
||||
logInfo(
|
||||
`✅ Sent web reply to ${msg.from} (${(Date.now() - replyStarted).toFixed(0)}ms)`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
replyLogger.info(
|
||||
{
|
||||
correlationId: msg.id ?? newConnectionId(),
|
||||
connectionId: connectionId ?? null,
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: replyResult.text,
|
||||
mediaUrl: null,
|
||||
mediaSizeBytes: null,
|
||||
mediaKind: null,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
},
|
||||
"auto-reply sent (text)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanText = replyResult.text ?? undefined;
|
||||
for (const [index, mediaUrl] of mediaList.entries()) {
|
||||
try {
|
||||
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
|
||||
);
|
||||
logVerbose(
|
||||
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
|
||||
);
|
||||
}
|
||||
const caption = index === 0 ? cleanText || undefined : undefined;
|
||||
if (media.kind === "image") {
|
||||
await msg.sendMedia({
|
||||
image: media.buffer,
|
||||
caption,
|
||||
mimetype: media.contentType,
|
||||
});
|
||||
} else if (media.kind === "audio") {
|
||||
await msg.sendMedia({
|
||||
audio: media.buffer,
|
||||
ptt: true,
|
||||
mimetype: media.contentType,
|
||||
caption,
|
||||
});
|
||||
} else if (media.kind === "video") {
|
||||
await msg.sendMedia({
|
||||
video: media.buffer,
|
||||
caption,
|
||||
mimetype: media.contentType,
|
||||
});
|
||||
} else {
|
||||
const fileName = mediaUrl.split("/").pop() ?? "file";
|
||||
const mimetype = media.contentType ?? "application/octet-stream";
|
||||
await msg.sendMedia({
|
||||
document: media.buffer,
|
||||
fileName,
|
||||
caption,
|
||||
mimetype,
|
||||
});
|
||||
}
|
||||
logInfo(
|
||||
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
|
||||
runtime,
|
||||
);
|
||||
replyLogger.info(
|
||||
{
|
||||
correlationId: msg.id ?? newConnectionId(),
|
||||
connectionId: connectionId ?? null,
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: index === 0 ? (cleanText ?? null) : null,
|
||||
mediaUrl,
|
||||
mediaSizeBytes: media.buffer.length,
|
||||
mediaKind: media.kind,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
},
|
||||
"auto-reply sent (media)",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
|
||||
);
|
||||
if (index === 0 && cleanText) {
|
||||
console.log(`⚠️ Media skipped; sent text-only to ${msg.from}`);
|
||||
await msg.reply(cleanText || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorWebProvider(
|
||||
verbose: boolean,
|
||||
listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox,
|
||||
keepAlive = true,
|
||||
replyResolver: typeof getReplyFromConfig | undefined = getReplyFromConfig,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
abortSignal?: AbortSignal,
|
||||
tuning: WebMonitorTuning = {},
|
||||
) {
|
||||
const runId = newConnectionId();
|
||||
const replyLogger = getChildLogger({ module: "web-auto-reply", runId });
|
||||
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
|
||||
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
|
||||
const cfg = loadConfig();
|
||||
const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb;
|
||||
const maxMediaBytes =
|
||||
typeof configuredMaxMb === "number" && configuredMaxMb > 0
|
||||
? configuredMaxMb * 1024 * 1024
|
||||
: DEFAULT_WEB_MEDIA_BYTES;
|
||||
const heartbeatSeconds = resolveHeartbeatSeconds(
|
||||
cfg,
|
||||
tuning.heartbeatSeconds,
|
||||
);
|
||||
const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes(
|
||||
cfg,
|
||||
tuning.replyHeartbeatMinutes,
|
||||
);
|
||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||
const sleep =
|
||||
tuning.sleep ??
|
||||
((ms: number, signal?: AbortSignal) =>
|
||||
sleepWithAbort(ms, signal ?? abortSignal));
|
||||
const stopRequested = () => abortSignal?.aborted === true;
|
||||
const abortPromise =
|
||||
abortSignal &&
|
||||
new Promise<"aborted">((resolve) =>
|
||||
abortSignal.addEventListener("abort", () => resolve("aborted"), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
|
||||
let sigintStop = false;
|
||||
const handleSigint = () => {
|
||||
sigintStop = true;
|
||||
};
|
||||
process.once("SIGINT", handleSigint);
|
||||
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
while (true) {
|
||||
if (stopRequested()) break;
|
||||
|
||||
const connectionId = newConnectionId();
|
||||
const startedAt = Date.now();
|
||||
let heartbeat: NodeJS.Timeout | null = null;
|
||||
let replyHeartbeatTimer: NodeJS.Timeout | null = null;
|
||||
let lastMessageAt: number | null = null;
|
||||
let handledMessages = 0;
|
||||
let lastInboundMsg: WebInboundMsg | null = null;
|
||||
|
||||
const listener = await (listenerFactory ?? monitorWebInbox)({
|
||||
verbose,
|
||||
onMessage: async (msg) => {
|
||||
handledMessages += 1;
|
||||
lastMessageAt = Date.now();
|
||||
const ts = msg.timestamp
|
||||
? new Date(msg.timestamp).toISOString()
|
||||
: new Date().toISOString();
|
||||
const correlationId = msg.id ?? newConnectionId();
|
||||
replyLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
correlationId,
|
||||
from: msg.from,
|
||||
to: msg.to,
|
||||
body: msg.body,
|
||||
mediaType: msg.mediaType ?? null,
|
||||
mediaPath: msg.mediaPath ?? null,
|
||||
},
|
||||
"inbound web message",
|
||||
);
|
||||
|
||||
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
||||
|
||||
lastInboundMsg = msg;
|
||||
|
||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||
{
|
||||
Body: msg.body,
|
||||
From: msg.from,
|
||||
To: msg.to,
|
||||
MessageSid: msg.id,
|
||||
MediaPath: msg.mediaPath,
|
||||
MediaUrl: msg.mediaUrl,
|
||||
MediaType: msg.mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: msg.sendComposing,
|
||||
},
|
||||
);
|
||||
if (
|
||||
!replyResult ||
|
||||
(!replyResult.text &&
|
||||
!replyResult.mediaUrl &&
|
||||
!replyResult.mediaUrls?.length)
|
||||
) {
|
||||
logVerbose(
|
||||
"Skipping auto-reply: no text/media returned from resolver",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliverWebReply({
|
||||
replyResult,
|
||||
msg,
|
||||
maxMediaBytes,
|
||||
replyLogger,
|
||||
runtime,
|
||||
connectionId,
|
||||
});
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
success(
|
||||
`↩️ Auto-replied to ${msg.from} (web${replyResult.mediaUrl || replyResult.mediaUrls?.length ? ", media" : ""})`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
success(
|
||||
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl || replyResult.mediaUrls?.length ? " (media)" : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
danger(
|
||||
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const closeListener = async () => {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer);
|
||||
try {
|
||||
await listener.close();
|
||||
} catch (err) {
|
||||
logVerbose(`Socket close failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (keepAlive) {
|
||||
heartbeat = setInterval(() => {
|
||||
const authAgeMs = getWebAuthAgeMs();
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
reconnectAttempts,
|
||||
messagesHandled: handledMessages,
|
||||
lastMessageAt,
|
||||
authAgeMs,
|
||||
uptimeMs: Date.now() - startedAt,
|
||||
},
|
||||
"web relay heartbeat",
|
||||
);
|
||||
}, heartbeatSeconds * 1000);
|
||||
}
|
||||
|
||||
const runReplyHeartbeat = async () => {
|
||||
if (!replyHeartbeatMinutes) return;
|
||||
const tickStart = Date.now();
|
||||
if (!lastInboundMsg) {
|
||||
const fallbackTo = getFallbackRecipient(cfg);
|
||||
if (!fallbackTo) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
reason: "no-recent-inbound",
|
||||
durationMs: Date.now() - tickStart,
|
||||
},
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: skipped (no recent inbound)"));
|
||||
return;
|
||||
}
|
||||
const snapshot = getSessionSnapshot(cfg, fallbackTo, true);
|
||||
if (!snapshot.entry) {
|
||||
heartbeatLogger.info(
|
||||
{ connectionId, to: fallbackTo, reason: "no-session-for-fallback" },
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: skipped (no session to resume)"));
|
||||
return;
|
||||
}
|
||||
if (isVerbose()) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
to: fallbackTo,
|
||||
reason: "fallback-session",
|
||||
sessionId: snapshot.entry?.sessionId ?? null,
|
||||
sessionFresh: snapshot.fresh,
|
||||
},
|
||||
"reply heartbeat start",
|
||||
);
|
||||
}
|
||||
await runWebHeartbeatOnce({
|
||||
cfg,
|
||||
to: fallbackTo,
|
||||
verbose,
|
||||
replyResolver,
|
||||
runtime,
|
||||
sessionId: snapshot.entry.sessionId,
|
||||
});
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
to: fallbackTo,
|
||||
...snapshot,
|
||||
durationMs: Date.now() - tickStart,
|
||||
},
|
||||
"reply heartbeat sent (fallback session)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from);
|
||||
if (isVerbose()) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
to: lastInboundMsg.from,
|
||||
intervalMinutes: replyHeartbeatMinutes,
|
||||
sessionKey: snapshot.key,
|
||||
sessionId: snapshot.entry?.sessionId ?? null,
|
||||
sessionFresh: snapshot.fresh,
|
||||
},
|
||||
"reply heartbeat start",
|
||||
);
|
||||
}
|
||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||
{
|
||||
Body: HEARTBEAT_PROMPT,
|
||||
From: lastInboundMsg.from,
|
||||
To: lastInboundMsg.to,
|
||||
MessageSid: snapshot.entry?.sessionId,
|
||||
MediaPath: undefined,
|
||||
MediaUrl: undefined,
|
||||
MediaType: undefined,
|
||||
},
|
||||
{
|
||||
onReplyStart: lastInboundMsg.sendComposing,
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
!replyResult ||
|
||||
(!replyResult.text &&
|
||||
!replyResult.mediaUrl &&
|
||||
!replyResult.mediaUrls?.length)
|
||||
) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
durationMs: Date.now() - tickStart,
|
||||
reason: "empty-reply",
|
||||
},
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: ok (empty reply)"));
|
||||
return;
|
||||
}
|
||||
|
||||
const stripped = stripHeartbeatToken(replyResult.text);
|
||||
const hasMedia = Boolean(
|
||||
replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
durationMs: Date.now() - tickStart,
|
||||
reason: "heartbeat-token",
|
||||
rawLength: replyResult.text?.length ?? 0,
|
||||
},
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
console.log(success("heartbeat: ok (HEARTBEAT_OK)"));
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedReply: ReplyPayload = {
|
||||
...replyResult,
|
||||
text: stripped.text,
|
||||
};
|
||||
|
||||
await deliverWebReply({
|
||||
replyResult: cleanedReply,
|
||||
msg: lastInboundMsg,
|
||||
maxMediaBytes,
|
||||
replyLogger,
|
||||
runtime,
|
||||
connectionId,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - tickStart;
|
||||
const summary = `heartbeat: alert sent (${formatDuration(durationMs)})`;
|
||||
console.log(summary);
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
durationMs,
|
||||
hasMedia,
|
||||
chars: stripped.text?.length ?? 0,
|
||||
},
|
||||
"reply heartbeat sent",
|
||||
);
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - tickStart;
|
||||
heartbeatLogger.warn(
|
||||
{
|
||||
connectionId,
|
||||
error: String(err),
|
||||
durationMs,
|
||||
},
|
||||
"reply heartbeat failed",
|
||||
);
|
||||
console.log(
|
||||
danger(`heartbeat: failed (${formatDuration(durationMs)})`),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (replyHeartbeatMinutes && !replyHeartbeatTimer) {
|
||||
const intervalMs = replyHeartbeatMinutes * 60_000;
|
||||
replyHeartbeatTimer = setInterval(() => {
|
||||
void runReplyHeartbeat();
|
||||
}, intervalMs);
|
||||
if (tuning.replyHeartbeatNow) {
|
||||
void runReplyHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
logInfo(
|
||||
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
||||
runtime,
|
||||
);
|
||||
|
||||
if (!keepAlive) {
|
||||
await closeListener();
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = await Promise.race([
|
||||
listener.onClose?.catch((err) => {
|
||||
reconnectLogger.error(
|
||||
{ error: String(err) },
|
||||
"listener.onClose rejected",
|
||||
);
|
||||
return { status: 500, isLoggedOut: false, error: err };
|
||||
}) ?? waitForever(),
|
||||
abortPromise ?? waitForever(),
|
||||
]);
|
||||
|
||||
const uptimeMs = Date.now() - startedAt;
|
||||
if (uptimeMs > heartbeatSeconds * 1000) {
|
||||
reconnectAttempts = 0; // Healthy stretch; reset the backoff.
|
||||
}
|
||||
|
||||
if (stopRequested() || sigintStop || reason === "aborted") {
|
||||
await closeListener();
|
||||
break;
|
||||
}
|
||||
|
||||
const status =
|
||||
(typeof reason === "object" && reason && "status" in reason
|
||||
? (reason as { status?: number }).status
|
||||
: undefined) ?? "unknown";
|
||||
const loggedOut =
|
||||
typeof reason === "object" &&
|
||||
reason &&
|
||||
"isLoggedOut" in reason &&
|
||||
(reason as { isLoggedOut?: boolean }).isLoggedOut;
|
||||
|
||||
reconnectLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
status,
|
||||
loggedOut,
|
||||
reconnectAttempts,
|
||||
},
|
||||
"web reconnect: connection closed",
|
||||
);
|
||||
|
||||
if (loggedOut) {
|
||||
runtime.error(
|
||||
danger(
|
||||
"WhatsApp session logged out. Run `warelay login --provider web` to relink.",
|
||||
),
|
||||
);
|
||||
await closeListener();
|
||||
break;
|
||||
}
|
||||
|
||||
reconnectAttempts += 1;
|
||||
if (
|
||||
reconnectPolicy.maxAttempts > 0 &&
|
||||
reconnectAttempts >= reconnectPolicy.maxAttempts
|
||||
) {
|
||||
reconnectLogger.warn(
|
||||
{
|
||||
connectionId,
|
||||
status,
|
||||
reconnectAttempts,
|
||||
maxAttempts: reconnectPolicy.maxAttempts,
|
||||
},
|
||||
"web reconnect: max attempts reached",
|
||||
);
|
||||
runtime.error(
|
||||
danger(
|
||||
`WhatsApp Web connection closed (status ${status}). Reached max retries (${reconnectPolicy.maxAttempts}); exiting so you can relink.`,
|
||||
),
|
||||
);
|
||||
await closeListener();
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||
reconnectLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
status,
|
||||
reconnectAttempts,
|
||||
maxAttempts: reconnectPolicy.maxAttempts || "unlimited",
|
||||
delayMs: delay,
|
||||
},
|
||||
"web reconnect: scheduling retry",
|
||||
);
|
||||
runtime.error(
|
||||
danger(
|
||||
`WhatsApp Web connection closed (status ${status}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDuration(delay)}…`,
|
||||
),
|
||||
);
|
||||
await closeListener();
|
||||
try {
|
||||
await sleep(delay, abortSignal);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
process.removeListener("SIGINT", handleSigint);
|
||||
}
|
||||
|
||||
export { DEFAULT_WEB_MEDIA_BYTES };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractMediaPlaceholder, extractText } from "./inbound.js";
|
||||
|
||||
describe("web inbound helpers", () => {
|
||||
it("prefers the main conversation body", () => {
|
||||
const body = extractText({
|
||||
conversation: " hello ",
|
||||
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
|
||||
expect(body).toBe("hello");
|
||||
});
|
||||
|
||||
it("falls back to captions when conversation text is missing", () => {
|
||||
const body = extractText({
|
||||
imageMessage: { caption: " caption " },
|
||||
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
|
||||
expect(body).toBe("caption");
|
||||
});
|
||||
|
||||
it("returns placeholders for media-only payloads", () => {
|
||||
expect(
|
||||
extractMediaPlaceholder({
|
||||
imageMessage: {},
|
||||
} as unknown as import("@whiskeysockets/baileys").proto.IMessage),
|
||||
).toBe("<media:image>");
|
||||
expect(
|
||||
extractMediaPlaceholder({
|
||||
audioMessage: {},
|
||||
} as unknown as import("@whiskeysockets/baileys").proto.IMessage),
|
||||
).toBe("<media:audio>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import type {
|
||||
AnyMessageContent,
|
||||
proto,
|
||||
WAMessage,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import {
|
||||
DisconnectReason,
|
||||
downloadMediaMessage,
|
||||
} from "@whiskeysockets/baileys";
|
||||
|
||||
import { isVerbose, logVerbose } from "../globals.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { jidToE164 } from "../utils.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
getStatusCode,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
|
||||
export type WebListenerCloseReason = {
|
||||
status?: number;
|
||||
isLoggedOut: boolean;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type WebInboundMessage = {
|
||||
id?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
body: string;
|
||||
pushName?: string;
|
||||
timestamp?: number;
|
||||
sendComposing: () => Promise<void>;
|
||||
reply: (text: string) => Promise<void>;
|
||||
sendMedia: (payload: AnyMessageContent) => Promise<void>;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
mediaUrl?: string;
|
||||
};
|
||||
|
||||
export async function monitorWebInbox(options: {
|
||||
verbose: boolean;
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
}) {
|
||||
const inboundLogger = getChildLogger({ module: "web-inbound" });
|
||||
const sock = await createWaSocket(false, options.verbose);
|
||||
await waitForWaConnection(sock);
|
||||
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
|
||||
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
|
||||
onCloseResolve = resolve;
|
||||
});
|
||||
try {
|
||||
// Advertise that the relay is online right after connecting.
|
||||
await sock.sendPresenceUpdate("available");
|
||||
if (isVerbose()) logVerbose("Sent global 'available' presence on connect");
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`Failed to send 'available' presence on connect: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
const selfJid = sock.user?.id;
|
||||
const selfE164 = selfJid ? jidToE164(selfJid) : null;
|
||||
const seen = new Set<string>();
|
||||
|
||||
sock.ev.on("messages.upsert", async (upsert) => {
|
||||
if (upsert.type !== "notify") return;
|
||||
for (const msg of upsert.messages) {
|
||||
const id = msg.key?.id ?? undefined;
|
||||
// De-dupe on message id; Baileys can emit retries.
|
||||
if (id && seen.has(id)) continue;
|
||||
if (id) seen.add(id);
|
||||
if (msg.key?.fromMe) continue;
|
||||
const remoteJid = msg.key?.remoteJid;
|
||||
if (!remoteJid) continue;
|
||||
// Ignore status/broadcast traffic; we only care about direct chats.
|
||||
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
||||
continue;
|
||||
if (id) {
|
||||
const participant = msg.key?.participant;
|
||||
try {
|
||||
await sock.readMessages([
|
||||
{ remoteJid, id, participant, fromMe: false },
|
||||
]);
|
||||
if (isVerbose()) {
|
||||
const suffix = participant ? ` (participant ${participant})` : "";
|
||||
logVerbose(
|
||||
`Marked message ${id} as read for ${remoteJid}${suffix}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
const from = jidToE164(remoteJid);
|
||||
if (!from) continue;
|
||||
let body = extractText(msg.message ?? undefined);
|
||||
if (!body) {
|
||||
body = extractMediaPlaceholder(msg.message ?? undefined);
|
||||
if (!body) continue;
|
||||
}
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
try {
|
||||
const inboundMedia = await downloadInboundMedia(msg, sock);
|
||||
if (inboundMedia) {
|
||||
const saved = await saveMediaBuffer(
|
||||
inboundMedia.buffer,
|
||||
inboundMedia.mimetype,
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
mediaType = inboundMedia.mimetype;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Inbound media download failed: ${String(err)}`);
|
||||
}
|
||||
const chatJid = remoteJid;
|
||||
const sendComposing = async () => {
|
||||
try {
|
||||
await sock.sendPresenceUpdate("composing", chatJid);
|
||||
} catch (err) {
|
||||
logVerbose(`Presence update failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
const reply = async (text: string) => {
|
||||
await sock.sendMessage(chatJid, { text });
|
||||
};
|
||||
const sendMedia = async (payload: AnyMessageContent) => {
|
||||
await sock.sendMessage(chatJid, payload);
|
||||
};
|
||||
const timestamp = msg.messageTimestamp
|
||||
? Number(msg.messageTimestamp) * 1000
|
||||
: undefined;
|
||||
inboundLogger.info(
|
||||
{
|
||||
from,
|
||||
to: selfE164 ?? "me",
|
||||
body,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
timestamp,
|
||||
},
|
||||
"inbound message",
|
||||
);
|
||||
try {
|
||||
await options.onMessage({
|
||||
id,
|
||||
from,
|
||||
to: selfE164 ?? "me",
|
||||
body,
|
||||
pushName: msg.pushName ?? undefined,
|
||||
timestamp,
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed handling inbound web message:", String(err));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on(
|
||||
"connection.update",
|
||||
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
|
||||
try {
|
||||
if (update.connection === "close") {
|
||||
const status = getStatusCode(update.lastDisconnect?.error);
|
||||
onCloseResolve?.({
|
||||
status,
|
||||
isLoggedOut: status === DisconnectReason.loggedOut,
|
||||
error: update.lastDisconnect?.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
inboundLogger.error(
|
||||
{ error: String(err) },
|
||||
"connection.update handler error",
|
||||
);
|
||||
onCloseResolve?.({
|
||||
status: undefined,
|
||||
isLoggedOut: false,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
try {
|
||||
sock.ws?.close();
|
||||
} catch (err) {
|
||||
logVerbose(`Socket close failed: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
onClose,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function extractText(
|
||||
message: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
if (!message) return undefined;
|
||||
if (typeof message.conversation === "string" && message.conversation.trim()) {
|
||||
return message.conversation.trim();
|
||||
}
|
||||
const extended = message.extendedTextMessage?.text;
|
||||
if (extended?.trim()) return extended.trim();
|
||||
const caption =
|
||||
message.imageMessage?.caption ?? message.videoMessage?.caption;
|
||||
if (caption?.trim()) return caption.trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractMediaPlaceholder(
|
||||
message: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
if (!message) return undefined;
|
||||
if (message.imageMessage) return "<media:image>";
|
||||
if (message.videoMessage) return "<media:video>";
|
||||
if (message.audioMessage) return "<media:audio>";
|
||||
if (message.documentMessage) return "<media:document>";
|
||||
if (message.stickerMessage) return "<media:sticker>";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function downloadInboundMedia(
|
||||
msg: proto.IWebMessageInfo,
|
||||
sock: Awaited<ReturnType<typeof createWaSocket>>,
|
||||
): Promise<{ buffer: Buffer; mimetype?: string } | undefined> {
|
||||
const message = msg.message;
|
||||
if (!message) return undefined;
|
||||
const mimetype =
|
||||
message.imageMessage?.mimetype ??
|
||||
message.videoMessage?.mimetype ??
|
||||
message.documentMessage?.mimetype ??
|
||||
message.audioMessage?.mimetype ??
|
||||
message.stickerMessage?.mimetype ??
|
||||
undefined;
|
||||
if (
|
||||
!message.imageMessage &&
|
||||
!message.videoMessage &&
|
||||
!message.documentMessage &&
|
||||
!message.audioMessage &&
|
||||
!message.stickerMessage
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const buffer = (await downloadMediaMessage(
|
||||
msg as WAMessage,
|
||||
"buffer",
|
||||
{},
|
||||
{
|
||||
reuploadRequest: sock.updateMediaMessage,
|
||||
logger: sock.logger,
|
||||
},
|
||||
)) as Buffer;
|
||||
return { buffer, mimetype };
|
||||
} catch (err) {
|
||||
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import { DisconnectReason } from "@whiskeysockets/baileys";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
const rmMock = vi.spyOn(fs, "rm");
|
||||
|
||||
vi.mock("./session.js", () => {
|
||||
const sockA = { ws: { close: vi.fn() } };
|
||||
const sockB = { ws: { close: vi.fn() } };
|
||||
const createWaSocket = vi.fn(async () =>
|
||||
createWaSocket.mock.calls.length === 0 ? sockA : sockB,
|
||||
);
|
||||
const waitForWaConnection = vi.fn();
|
||||
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
|
||||
return {
|
||||
createWaSocket,
|
||||
waitForWaConnection,
|
||||
formatError,
|
||||
WA_WEB_AUTH_DIR: "/tmp/wa-creds",
|
||||
};
|
||||
});
|
||||
|
||||
const { createWaSocket, waitForWaConnection, formatError } = await import(
|
||||
"./session.js"
|
||||
);
|
||||
const { loginWeb } = await import("./login.js");
|
||||
|
||||
describe("loginWeb coverage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rmMock.mockClear();
|
||||
});
|
||||
|
||||
it("restarts once when WhatsApp requests code 515", async () => {
|
||||
waitForWaConnection
|
||||
.mockRejectedValueOnce({ output: { statusCode: 515 } })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const runtime = { log: vi.fn(), error: vi.fn() } as never;
|
||||
await loginWeb(false, waitForWaConnection as never, runtime);
|
||||
|
||||
expect(createWaSocket).toHaveBeenCalledTimes(2);
|
||||
const firstSock = await createWaSocket.mock.results[0].value;
|
||||
expect(firstSock.ws.close).toHaveBeenCalled();
|
||||
vi.runAllTimers();
|
||||
const secondSock = await createWaSocket.mock.results[1].value;
|
||||
expect(secondSock.ws.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears creds and throws when logged out", async () => {
|
||||
waitForWaConnection.mockRejectedValueOnce({
|
||||
output: { statusCode: DisconnectReason.loggedOut },
|
||||
});
|
||||
|
||||
await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow(
|
||||
/cache cleared/i,
|
||||
);
|
||||
expect(rmMock).toHaveBeenCalledWith("/tmp/wa-creds", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats and rethrows generic errors", async () => {
|
||||
waitForWaConnection.mockRejectedValueOnce(new Error("boom"));
|
||||
await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow(
|
||||
"formatted:Error: boom",
|
||||
);
|
||||
expect(formatError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user