Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bafe9483d | ||
|
|
4e3663b4d4 | ||
|
|
12d7be7cad | ||
|
|
84f2595349 | ||
|
|
c11abc1134 | ||
|
|
f63bdda628 | ||
|
|
7d6a4f5204 | ||
|
|
f871869c79 | ||
|
|
8ebe72951f | ||
|
|
8d4b31a301 | ||
|
|
8912b3e035 | ||
|
|
f5d7057042 | ||
|
|
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 | ||
|
|
f81f432af5 | ||
|
|
dda017df23 | ||
|
|
46be5eac7d | ||
|
|
e4076d14c0 | ||
|
|
20fc412765 | ||
|
|
c251681a40 |
No files matched your search
@@ -21,6 +21,7 @@
|
|||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||||
|
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||||
|
|||||||
@@ -1,5 +1,51 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.2.2 — 2025-11-28
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- **Manual heartbeat sends:** `warelay heartbeat` accepts `--message/--body` with `--provider web|twilio` to push real outbound messages through the same plumbing; `--dry-run` previews payloads without sending.
|
||||||
|
|
||||||
|
## 1.2.1 — 2025-11-28
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- **Media MIME-first handling:** Media loading now sniffs magic bytes/header before trusting extensions for both providers; local files with the wrong suffix still get correct MIME and image recompression.
|
||||||
|
- **Hosted media extensions:** Saved/hosted media (web inbound, webhook hosting, Twilio hosting) now writes files with an extension derived from detected MIME (e.g., `.jpg`, `.png`, `.mp4`), so downstream CLI sends carry the right Content-Type. Added tests covering inbound Baileys downloads and buffer saves.
|
||||||
|
|
||||||
|
### Planned / in progress
|
||||||
|
- **Heartbeat targeting quality:** Allow `warelay heartbeat --provider web --all` to fall back to `inbound.allowFrom` when no sessions exist, and surface a clear error when neither sessions nor allow-list entries are present. Add verbose log lines that state exactly which recipients were chosen and why.
|
||||||
|
- **Heartbeat delivery preview (Claude path):** Add a dry-run mode that resolves the heartbeat reply (text/media) and prints it without sending, to help test Claude prompt changes safely.
|
||||||
|
- **Simulated inbound hook (debug):** Optional local-only endpoint to inject synthetic inbound messages into the web relay loop, sharing the same command queue and reply path. Useful for testing auto-replies and heartbeats without WhatsApp.
|
||||||
|
|
||||||
|
## 1.2.0 — 2025-11-27
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Auto-replies now send a WhatsApp fallback message when a command/Claude run hits the timeout, including up to 800 chars of partial stdout so the user still sees progress.
|
||||||
|
- Added tests covering the new timeout fallback behavior and partial-output truncation.
|
||||||
|
- Web relay auto-reconnects after Baileys/WebSocket drops (with log-out detection) and exposes close events for monitoring; added tests for close propagation and reconnect loop.
|
||||||
|
|
||||||
## 0.1.3 — 2025-11-25
|
## 0.1.3 — 2025-11-25
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -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).
|
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/clawd.md`](https://github.com/steipete/warelay/blob/main/docs/clawd.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)
|
## Quick Start (pick your engine)
|
||||||
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
|
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
|
||||||
|
|
||||||
**A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)**
|
**A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)**
|
||||||
1. Link your account: `warelay login` (scan the QR).
|
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).
|
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)**
|
**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`).
|
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 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 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 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 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` |
|
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
||||||
|
|
||||||
### Sending images
|
### 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).
|
- 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: `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).
|
- 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
|
## Providers
|
||||||
- **Twilio (default):** needs `.env` creds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators.
|
- **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).
|
- **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` uses Web when logged in, otherwise Twilio polling.
|
- **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.
|
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",
|
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
|
||||||
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
|
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
|
||||||
claudeOutputFormat: "text",
|
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)
|
### 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.
|
- 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`:
|
- 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)
|
### 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.
|
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).
|
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.
|
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)
|
### Auto-reply parameter table (compact)
|
||||||
| Key | Type & default | Notes |
|
| 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.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
|
||||||
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
|
| `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. |
|
| `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.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.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.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.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.sessionArgResume` | `string[]` (default: `["--resume","{{SessionId}}"]`) | Args for resumed sessions. |
|
||||||
| `inbound.reply.session.sessionArgBeforeBody` | `boolean` (default: `true`) | Place session args before final body arg. |
|
| `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/`.
|
- 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.
|
- 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
|
## 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.
|
- 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`).
|
- 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",
|
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentWidth": 2
|
"indentWidth": 2,
|
||||||
|
"indentStyle": "space"
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
+1
-1
@@ -30,6 +30,6 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
|||||||
|
|
||||||
6) **Post-publish**
|
6) **Post-publish**
|
||||||
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
||||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z`; body should be the product-facing bullets from the changelog; attach the `npm pack` tarball + checksums if you generated them.
|
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `warelay X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**; attach the `npm pack` tarball + checksums if you generated them.
|
||||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y warelay@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
- [ ] From a clean temp directory (no `package.json`), run `npx -y warelay@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||||
- [ ] Announce/share release notes.
|
- [ ] Announce/share release notes.
|
||||||
@@ -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.
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# Claude Auto-Reply Setup (2025-11-25)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
- Node 22+, `warelay` installed globally (`npm install -g warelay`) or run via `pnpm warelay` inside the repo.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Create your warelay config
|
|
||||||
warelay reads `~/.warelay/warelay.json` (JSON5 accepted). Add a command-mode reply that points at the Claude CLI:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
inbound: {
|
|
||||||
// Only people in this list can trigger the command reply (remove to allow anyone).
|
|
||||||
allowFrom: ["+15551234567"],
|
|
||||||
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,
|
|
||||||
session: {
|
|
||||||
scope: "per-sender", // keep conversation per phone number
|
|
||||||
resetTriggers: ["/new"], // send "/new" to reset context
|
|
||||||
idleMinutes: 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Minimal text-only variant
|
|
||||||
If you just want short text replies and no sessions:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
inbound: {
|
|
||||||
reply: {
|
|
||||||
mode: "command",
|
|
||||||
command: ["claude", "{{Body}}"],
|
|
||||||
claudeOutputFormat: "text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This still benefits from the queue, typing indicators, and provider auto-selection.
|
|
||||||
+484
@@ -0,0 +1,484 @@
|
|||||||
|
# Building Your Own AI Personal Assistant with warelay
|
||||||
|
|
||||||
|
> **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: `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
|
||||||
|
|
||||||
|
## 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: {
|
||||||
|
allowFrom: ["+1234567890"], // your phone number
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
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",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Heartbeats: Your Proactive Assistant
|
||||||
|
|
||||||
|
This is where warelay gets interesting. Every 10 minutes (configurable), warelay pings Claude with:
|
||||||
|
|
||||||
|
```
|
||||||
|
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 do **real work**, not just check in:
|
||||||
|
|
||||||
|
- 🔋 **Monitor battery** - `pmset -g batt` - warns <30%, critical <15%
|
||||||
|
- 📅 **Calendar** - checks upcoming meetings in next 2 hours
|
||||||
|
- 📧 **Email** - scans inbox for urgent/important unread messages
|
||||||
|
- 🐦 **Twitter** - checks @mentions and replies worth seeing (via browser-tools)
|
||||||
|
- 📺 **TV Shows** - reminds about new episodes of shows you're watching
|
||||||
|
- 🏰 **Server health** - SSH to verify backup servers are running
|
||||||
|
- ✈️ **Flights** - reminds about upcoming travel
|
||||||
|
- 🧹 **Home tidying** - occasionally cleans temp files, updates memories
|
||||||
|
- ⏰ **Wake-up alarms** - triggers voice + music alarms at scheduled times
|
||||||
|
- 💡 **Surprise** - occasionally shares something fun or interesting
|
||||||
|
|
||||||
|
The key insight: heartbeats let your AI be **proactive**, not just reactive. Configure what matters to you!
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
inbound: {
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["claude", "{{Body}}"],
|
||||||
|
claudeOutputFormat: "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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` |
|
||||||
|
| **[browser-tools](https://github.com/steipete/agent-scripts)** | Chrome DevTools CLI - navigate, screenshot, eval JS, extract DOM | Clone repo |
|
||||||
|
| **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.
|
||||||
|
|
||||||
|
### browser-tools for Web Scraping
|
||||||
|
|
||||||
|
[browser-tools](https://github.com/steipete/agent-scripts) is a lightweight Chrome DevTools CLI that doesn't require MCP (saves ~17k tokens!). Great for reading tweets, scraping pages, or automating browser tasks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start Chrome with your profile (logged into sites)
|
||||||
|
~/Projects/agent-scripts/bin/browser-tools start --profile
|
||||||
|
|
||||||
|
# Navigate and extract tweet content
|
||||||
|
browser-tools nav "https://x.com/steipete/status/123"
|
||||||
|
browser-tools eval 'Array.from(document.querySelectorAll("[data-testid=\"tweetText\"]")).map(el => el.innerText).join("\n")'
|
||||||
|
|
||||||
|
# Kill ONLY the devtools Chrome (your regular Chrome stays open!)
|
||||||
|
browser-tools kill --all --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Twitter Automation with Peekaboo + AppleScript
|
||||||
|
|
||||||
|
Clawd can reply to tweets autonomously using a combination of Peekaboo (for screenshots and typing) and AppleScript (for JavaScript injection). Here's the pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to a tweet
|
||||||
|
osascript -e 'tell application "Google Chrome" to set URL of active tab of front window to "https://x.com/user/status/123"'
|
||||||
|
|
||||||
|
# Screenshot to see current state
|
||||||
|
peekaboo image --mode screen --path /tmp/twitter.png
|
||||||
|
|
||||||
|
# Scroll the page
|
||||||
|
osascript -e 'tell application "Google Chrome" to execute front window'\''s active tab javascript "window.scrollBy(0, 500)"'
|
||||||
|
|
||||||
|
# Focus the reply input (Twitter-specific selector)
|
||||||
|
osascript -e 'tell application "Google Chrome" to execute front window'\''s active tab javascript "
|
||||||
|
const replyInput = document.querySelector(\"[data-testid=\\\"tweetTextarea_0\\\"]\");
|
||||||
|
if (replyInput) { replyInput.focus(); replyInput.click(); }
|
||||||
|
"'
|
||||||
|
|
||||||
|
# Type the reply with Peekaboo
|
||||||
|
peekaboo type "Your reply here 🦞" --app "Google Chrome"
|
||||||
|
|
||||||
|
# Click Reply button (JS injection more reliable than Peekaboo clicks on Twitter)
|
||||||
|
osascript -e 'tell application "Google Chrome" to execute front window'\''s active tab javascript "
|
||||||
|
const buttons = document.querySelectorAll(\"[role=\\\"button\\\"]\");
|
||||||
|
buttons.forEach(b => { if (b.innerText === \"Reply\") b.click(); });
|
||||||
|
"'
|
||||||
|
|
||||||
|
# Find tweet URLs from the page
|
||||||
|
osascript -e 'tell application "Google Chrome" to execute front window'\''s active tab javascript "
|
||||||
|
const tweet = document.querySelector(\"article\");
|
||||||
|
tweet?.querySelector(\"time\")?.parentElement?.href;
|
||||||
|
"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pro tip:** JavaScript injection via AppleScript is more reliable than Peekaboo clicks for Twitter's dynamic UI. Use Peekaboo for typing and screenshots, AppleScript for navigation and button clicks.
|
||||||
|
|
||||||
|
### Music Recognition with audd.io
|
||||||
|
|
||||||
|
Identify songs from audio clips (voice messages, recordings):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://api.audd.io/" \
|
||||||
|
-F "api_token=test" \
|
||||||
|
-F "file=@/path/to/audio.ogg" \
|
||||||
|
-F "return=spotify"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns song title, artist, album, and Spotify link. Works great for identifying songs playing in the background!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See It In Action
|
||||||
|
|
||||||
|
Check out these tweets showing warelay + Clawd in the wild:
|
||||||
|
|
||||||
|
- [Clawd with full system access via WhatsApp](https://x.com/steipete/status/1993342394184745270) - "I'll be nice to Clawd"
|
||||||
|
- [Voice support - talk with Clawd on the go](https://x.com/steipete/status/1993455673229840588) - and it talks back!
|
||||||
|
- [Wake-up alarm demo](https://x.com/steipete/status/1994089740367253572) - "Took me 2 days to glue things together. Didn't even need 150 Million in funding."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built by [@steipete](https://twitter.com/steipete) and Clawd (they/them) — yes, Clawd helped write their own docs. 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.
|
||||||
+13
-6
@@ -5,6 +5,7 @@ This document defines how `warelay` should handle sending and replying with imag
|
|||||||
## Goals
|
## Goals
|
||||||
- Allow sending an image with an optional caption via `warelay send` for both providers.
|
- 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.
|
- 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.
|
- 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.
|
- Avoid introducing new external services: reuse the existing Tailscale Funnel port to host media for Twilio.
|
||||||
|
|
||||||
@@ -21,9 +22,13 @@ This document defines how `warelay` should handle sending and replying with imag
|
|||||||
## Provider Behavior
|
## Provider Behavior
|
||||||
### Web (Baileys)
|
### Web (Baileys)
|
||||||
- Input: local file path **or** HTTP(S) URL.
|
- 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.
|
||||||
|
- MIME is detected by magic bytes first (then header, then path); wrong file extensions are tolerated and the detected MIME drives payload kind and recompression.
|
||||||
- Caption uses `--message` or `reply.text`; if caption is empty, send media-only.
|
- 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
|
||||||
- Twilio API requires a public HTTPS `MediaUrl`; it will not accept local paths.
|
- Twilio API requires a public HTTPS `MediaUrl`; it will not accept local paths.
|
||||||
@@ -31,7 +36,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`.
|
- 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>`.
|
- `MediaUrl` = `https://<tailnet-host>.ts.net/media/<uuid>`.
|
||||||
- Files auto-removed after TTL (default 2 minutes) or after first successful fetch (best-effort).
|
- 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.
|
- 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).
|
- 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).
|
||||||
|
|
||||||
@@ -41,7 +46,7 @@ This document defines how `warelay` should handle sending and replying with imag
|
|||||||
- 404/410 if expired or missing.
|
- 404/410 if expired or missing.
|
||||||
- Optional `?delete=1` to self-delete after fetch (used by Twilio fetch hook if we detect first hit).
|
- Optional `?delete=1` to self-delete after fetch (used by Twilio fetch hook if we detect first hit).
|
||||||
- Temp storage: `~/.warelay/media`; cleaned on startup (remove files older than 15 minutes) and during TTL eviction.
|
- Temp storage: `~/.warelay/media`; cleaned on startup (remove files older than 15 minutes) and during TTL eviction.
|
||||||
- Security: no directory listing; only UUID file names; CORS open (Twilio fetch); content-type derived from `mime-types` lookup by extension or `content-type` header on download, else `application/octet-stream`.
|
- Security: no directory listing; only UUID file names; CORS open (Twilio fetch); content-type derived from sniffed bytes (fallback to header, then extension). Saved files are renamed with an extension that matches the detected MIME so downstream fetches present the correct type.
|
||||||
|
|
||||||
## Auto-Reply Pipeline
|
## Auto-Reply Pipeline
|
||||||
- `getReplyFromConfig` returns `{ text?, mediaUrl? }`.
|
- `getReplyFromConfig` returns `{ text?, mediaUrl? }`.
|
||||||
@@ -55,11 +60,13 @@ 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:
|
- For completeness: when inbound Twilio/Web messages include media, download to temp file, expose templating variables:
|
||||||
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
|
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
|
||||||
- `{{MediaPath}}` local temp path written before running the command.
|
- `{{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).
|
||||||
|
- Saved inbound media is named with the detected MIME-based extension (e.g., `.jpg`), so later CLI sends reuse a correct filename/content-type even if WhatsApp omitted an extension.
|
||||||
|
- 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
|
## 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.”
|
- 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.”
|
- Download failure for web provider → “Failed to load media from <source>; skipping send.”
|
||||||
|
|
||||||
## Tests to Add
|
## 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",
|
"name": "warelay",
|
||||||
"version": "0.1.3",
|
"version": "1.2.2",
|
||||||
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
|
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"pino": "^10.1.0",
|
"pino": "^10.1.0",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"twilio": "^5.10.6",
|
"twilio": "^5.10.6",
|
||||||
"zod": "^4.1.13"
|
"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";
|
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
|
||||||
|
|
||||||
describe("claude JSON parsing", () => {
|
describe("claude JSON parsing", () => {
|
||||||
it("extracts text from single JSON object", () => {
|
it("extracts text from single JSON object", () => {
|
||||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||||
expect(out).toBe("hello");
|
expect(out).toBe("hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts from newline-delimited JSON", () => {
|
it("extracts from newline-delimited JSON", () => {
|
||||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||||
expect(out).toBe("there");
|
expect(out).toBe("there");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined on invalid JSON", () => {
|
it("returns undefined on invalid JSON", () => {
|
||||||
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
||||||
const sample = {
|
const sample = {
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "success",
|
subtype: "success",
|
||||||
result: "hello from result field",
|
result: "hello from result field",
|
||||||
duration_ms: 1234,
|
duration_ms: 1234,
|
||||||
usage: { server_tool_use: { tool_a: 2 } },
|
usage: { server_tool_use: { tool_a: 2 } },
|
||||||
};
|
};
|
||||||
const parsed = parseClaudeJson(JSON.stringify(sample));
|
const parsed = parseClaudeJson(JSON.stringify(sample));
|
||||||
expect(parsed?.text).toBe("hello from result field");
|
expect(parsed?.text).toBe("hello from result field");
|
||||||
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
||||||
expect(parsed?.valid).toBe(true);
|
expect(parsed?.valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||||
expect(parsed?.valid).toBe(false);
|
expect(parsed?.valid).toBe(false);
|
||||||
expect(parsed?.text).toBeUndefined();
|
expect(parsed?.text).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+139
-139
@@ -4,159 +4,159 @@ import { z } from "zod";
|
|||||||
// Preferred binary name for Claude CLI invocations.
|
// Preferred binary name for Claude CLI invocations.
|
||||||
export const CLAUDE_BIN = "claude";
|
export const CLAUDE_BIN = "claude";
|
||||||
export const CLAUDE_IDENTITY_PREFIX =
|
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 {
|
function extractClaudeText(payload: unknown): string | undefined {
|
||||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||||
if (payload == null) return undefined;
|
if (payload == null) return undefined;
|
||||||
if (typeof payload === "string") return payload;
|
if (typeof payload === "string") return payload;
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
for (const item of payload) {
|
for (const item of payload) {
|
||||||
const found = extractClaudeText(item);
|
const found = extractClaudeText(item);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (typeof payload === "object") {
|
if (typeof payload === "object") {
|
||||||
const obj = payload as Record<string, unknown>;
|
const obj = payload as Record<string, unknown>;
|
||||||
if (typeof obj.result === "string") return obj.result;
|
if (typeof obj.result === "string") return obj.result;
|
||||||
if (typeof obj.text === "string") return obj.text;
|
if (typeof obj.text === "string") return obj.text;
|
||||||
if (typeof obj.completion === "string") return obj.completion;
|
if (typeof obj.completion === "string") return obj.completion;
|
||||||
if (typeof obj.output === "string") return obj.output;
|
if (typeof obj.output === "string") return obj.output;
|
||||||
if (obj.message) {
|
if (obj.message) {
|
||||||
const inner = extractClaudeText(obj.message);
|
const inner = extractClaudeText(obj.message);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj.messages)) {
|
if (Array.isArray(obj.messages)) {
|
||||||
const inner = extractClaudeText(obj.messages);
|
const inner = extractClaudeText(obj.messages);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj.content)) {
|
if (Array.isArray(obj.content)) {
|
||||||
for (const block of obj.content) {
|
for (const block of obj.content) {
|
||||||
if (
|
if (
|
||||||
block &&
|
block &&
|
||||||
typeof block === "object" &&
|
typeof block === "object" &&
|
||||||
(block as { type?: string }).type === "text" &&
|
(block as { type?: string }).type === "text" &&
|
||||||
typeof (block as { text?: unknown }).text === "string"
|
typeof (block as { text?: unknown }).text === "string"
|
||||||
) {
|
) {
|
||||||
return (block as { text: string }).text;
|
return (block as { text: string }).text;
|
||||||
}
|
}
|
||||||
const inner = extractClaudeText(block);
|
const inner = extractClaudeText(block);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClaudeJsonParseResult = {
|
export type ClaudeJsonParseResult = {
|
||||||
text?: string;
|
text?: string;
|
||||||
parsed: unknown;
|
parsed: unknown;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClaudeJsonSchema = z
|
const ClaudeJsonSchema = z
|
||||||
.object({
|
.object({
|
||||||
type: z.string().optional(),
|
type: z.string().optional(),
|
||||||
subtype: z.string().optional(),
|
subtype: z.string().optional(),
|
||||||
is_error: z.boolean().optional(),
|
is_error: z.boolean().optional(),
|
||||||
result: z.string().optional(),
|
result: z.string().optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
completion: z.string().optional(),
|
completion: z.string().optional(),
|
||||||
output: z.string().optional(),
|
output: z.string().optional(),
|
||||||
message: z.any().optional(),
|
message: z.any().optional(),
|
||||||
messages: z.any().optional(),
|
messages: z.any().optional(),
|
||||||
content: z.any().optional(),
|
content: z.any().optional(),
|
||||||
duration_ms: z.number().optional(),
|
duration_ms: z.number().optional(),
|
||||||
duration_api_ms: z.number().optional(),
|
duration_api_ms: z.number().optional(),
|
||||||
num_turns: z.number().optional(),
|
num_turns: z.number().optional(),
|
||||||
session_id: z.string().optional(),
|
session_id: z.string().optional(),
|
||||||
total_cost_usd: z.number().optional(),
|
total_cost_usd: z.number().optional(),
|
||||||
usage: z.record(z.string(), z.any()).optional(),
|
usage: z.record(z.string(), z.any()).optional(),
|
||||||
modelUsage: z.record(z.string(), z.any()).optional(),
|
modelUsage: z.record(z.string(), z.any()).optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
.refine(
|
.refine(
|
||||||
(obj) =>
|
(obj) =>
|
||||||
typeof obj.result === "string" ||
|
typeof obj.result === "string" ||
|
||||||
typeof obj.text === "string" ||
|
typeof obj.text === "string" ||
|
||||||
typeof obj.completion === "string" ||
|
typeof obj.completion === "string" ||
|
||||||
typeof obj.output === "string" ||
|
typeof obj.output === "string" ||
|
||||||
obj.message !== undefined ||
|
obj.message !== undefined ||
|
||||||
obj.messages !== undefined ||
|
obj.messages !== undefined ||
|
||||||
obj.content !== undefined,
|
obj.content !== undefined,
|
||||||
{ message: "Not a Claude JSON payload" },
|
{ message: "Not a Claude JSON payload" },
|
||||||
);
|
);
|
||||||
|
|
||||||
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
||||||
|
|
||||||
export function parseClaudeJson(
|
export function parseClaudeJson(
|
||||||
raw: string,
|
raw: string,
|
||||||
): ClaudeJsonParseResult | undefined {
|
): ClaudeJsonParseResult | undefined {
|
||||||
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
||||||
let firstParsed: unknown;
|
let firstParsed: unknown;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
raw,
|
raw,
|
||||||
...raw
|
...raw
|
||||||
.split(/\n+/)
|
.split(/\n+/)
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(candidate);
|
const parsed = JSON.parse(candidate);
|
||||||
if (firstParsed === undefined) firstParsed = parsed;
|
if (firstParsed === undefined) firstParsed = parsed;
|
||||||
let validation: ClaudeSafeParse | { success: false };
|
let validation: ClaudeSafeParse | { success: false };
|
||||||
try {
|
try {
|
||||||
validation = ClaudeJsonSchema.safeParse(parsed);
|
validation = ClaudeJsonSchema.safeParse(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
validation = { success: false } as const;
|
validation = { success: false } as const;
|
||||||
}
|
}
|
||||||
const validated = validation.success ? validation.data : parsed;
|
const validated = validation.success ? validation.data : parsed;
|
||||||
const isLikelyClaude =
|
const isLikelyClaude =
|
||||||
typeof validated === "object" &&
|
typeof validated === "object" &&
|
||||||
validated !== null &&
|
validated !== null &&
|
||||||
("result" in validated ||
|
("result" in validated ||
|
||||||
"text" in validated ||
|
"text" in validated ||
|
||||||
"completion" in validated ||
|
"completion" in validated ||
|
||||||
"output" in validated);
|
"output" in validated);
|
||||||
const text = extractClaudeText(validated);
|
const text = extractClaudeText(validated);
|
||||||
if (text)
|
if (text)
|
||||||
return {
|
return {
|
||||||
parsed: validated,
|
parsed: validated,
|
||||||
text,
|
text,
|
||||||
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
||||||
valid: Boolean(validation?.success || isLikelyClaude),
|
valid: Boolean(validation?.success || isLikelyClaude),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors; try next candidate
|
// ignore parse errors; try next candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (firstParsed !== undefined) {
|
if (firstParsed !== undefined) {
|
||||||
let validation: ClaudeSafeParse | { success: false };
|
let validation: ClaudeSafeParse | { success: false };
|
||||||
try {
|
try {
|
||||||
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
||||||
} catch {
|
} catch {
|
||||||
validation = { success: false } as const;
|
validation = { success: false } as const;
|
||||||
}
|
}
|
||||||
const validated = validation.success ? validation.data : firstParsed;
|
const validated = validation.success ? validation.data : firstParsed;
|
||||||
const isLikelyClaude =
|
const isLikelyClaude =
|
||||||
typeof validated === "object" &&
|
typeof validated === "object" &&
|
||||||
validated !== null &&
|
validated !== null &&
|
||||||
("result" in validated ||
|
("result" in validated ||
|
||||||
"text" in validated ||
|
"text" in validated ||
|
||||||
"completion" in validated ||
|
"completion" in validated ||
|
||||||
"output" in validated);
|
"output" in validated);
|
||||||
return {
|
return {
|
||||||
parsed: validated,
|
parsed: validated,
|
||||||
text: extractClaudeText(validated),
|
text: extractClaudeText(validated),
|
||||||
valid: Boolean(validation?.success || isLikelyClaude),
|
valid: Boolean(validation?.success || isLikelyClaude),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseClaudeJsonText(raw: string): string | undefined {
|
export function parseClaudeJsonText(raw: string): string | undefined {
|
||||||
const parsed = parseClaudeJson(raw);
|
const parsed = parseClaudeJson(raw);
|
||||||
return parsed?.text;
|
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
-453
@@ -1,497 +1,415 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||||
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
DEFAULT_RESET_TRIGGER,
|
DEFAULT_RESET_TRIGGER,
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { info, isVerbose, logVerbose } from "../globals.js";
|
import { info, isVerbose, logVerbose } from "../globals.js";
|
||||||
import { logError } from "../logger.js";
|
|
||||||
import { ensureMediaHosted } from "../media/host.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 { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import type { TwilioRequester } from "../twilio/types.js";
|
import type { TwilioRequester } from "../twilio/types.js";
|
||||||
import { sendTypingIndicator } from "../twilio/typing.js";
|
import { sendTypingIndicator } from "../twilio/typing.js";
|
||||||
|
import { runCommandReply } from "./command-reply.js";
|
||||||
import {
|
import {
|
||||||
CLAUDE_BIN,
|
applyTemplate,
|
||||||
CLAUDE_IDENTITY_PREFIX,
|
type MsgContext,
|
||||||
type ClaudeJsonParseResult,
|
type TemplateContext,
|
||||||
parseClaudeJson,
|
|
||||||
} from "./claude.js";
|
|
||||||
import {
|
|
||||||
applyTemplate,
|
|
||||||
type MsgContext,
|
|
||||||
type TemplateContext,
|
|
||||||
} from "./templating.js";
|
} from "./templating.js";
|
||||||
|
import { isAudio, transcribeInboundAudio } from "./transcription.js";
|
||||||
|
import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
type GetReplyOptions = {
|
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
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 async function getReplyFromConfig(
|
export async function getReplyFromConfig(
|
||||||
ctx: MsgContext,
|
ctx: MsgContext,
|
||||||
opts?: GetReplyOptions,
|
opts?: GetReplyOptions,
|
||||||
configOverride?: WarelayConfig,
|
configOverride?: WarelayConfig,
|
||||||
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
||||||
): Promise<ReplyPayload | undefined> {
|
): Promise<ReplyPayload | undefined> {
|
||||||
// Choose reply from config: static text or external command stdout.
|
// Choose reply from config: static text or external command stdout.
|
||||||
const cfg = configOverride ?? loadConfig();
|
const cfg = configOverride ?? loadConfig();
|
||||||
const reply = cfg.inbound?.reply;
|
const reply = cfg.inbound?.reply;
|
||||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||||
const timeoutMs = timeoutSeconds * 1000;
|
const timeoutMs = timeoutSeconds * 1000;
|
||||||
let started = false;
|
let started = false;
|
||||||
const onReplyStart = async () => {
|
const triggerTyping = async () => {
|
||||||
if (started) return;
|
await opts?.onReplyStart?.();
|
||||||
started = true;
|
};
|
||||||
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)
|
// Optional audio transcription before templating/session handling.
|
||||||
const sessionCfg = reply?.session;
|
if (cfg.inbound?.transcribeAudio && isAudio(ctx.MediaType)) {
|
||||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
|
||||||
? sessionCfg.resetTriggers
|
if (transcribed?.text) {
|
||||||
: [DEFAULT_RESET_TRIGGER];
|
transcribedText = transcribed.text;
|
||||||
const idleMinutes = Math.max(
|
ctx.Body = transcribed.text;
|
||||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
ctx.Transcript = transcribed.text;
|
||||||
1,
|
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||||
);
|
}
|
||||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
}
|
||||||
const storePath = resolveStorePath(sessionCfg?.store);
|
|
||||||
|
|
||||||
let sessionId: string | undefined;
|
// Optional session handling (conversation reuse + /new resets)
|
||||||
let isNewSession = false;
|
const sessionCfg = reply?.session;
|
||||||
let bodyStripped: string | undefined;
|
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) {
|
let sessionId: string | undefined;
|
||||||
const trimmedBody = (ctx.Body ?? "").trim();
|
let isNewSession = false;
|
||||||
for (const trigger of resetTriggers) {
|
let bodyStripped: string | undefined;
|
||||||
if (!trigger) continue;
|
let systemSent = false;
|
||||||
if (trimmedBody === trigger) {
|
|
||||||
isNewSession = true;
|
|
||||||
bodyStripped = "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const triggerPrefix = `${trigger} `;
|
|
||||||
if (trimmedBody.startsWith(triggerPrefix)) {
|
|
||||||
isNewSession = true;
|
|
||||||
bodyStripped = trimmedBody.slice(trigger.length).trimStart();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionKey = deriveSessionKey(sessionScope, ctx);
|
if (sessionCfg) {
|
||||||
const store = loadSessionStore(storePath);
|
const trimmedBody = (ctx.Body ?? "").trim();
|
||||||
const entry = store[sessionKey];
|
for (const trigger of resetTriggers) {
|
||||||
const idleMs = idleMinutes * 60_000;
|
if (!trigger) continue;
|
||||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
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) {
|
sessionKey = deriveSessionKey(sessionScope, ctx);
|
||||||
sessionId = entry.sessionId;
|
sessionStore = loadSessionStore(storePath);
|
||||||
} else {
|
const entry = sessionStore[sessionKey];
|
||||||
sessionId = crypto.randomUUID();
|
const idleMs = idleMinutes * 60_000;
|
||||||
isNewSession = true;
|
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||||
}
|
|
||||||
|
|
||||||
store[sessionKey] = { sessionId, updatedAt: Date.now() };
|
if (!isNewSession && freshEntry) {
|
||||||
await saveSessionStore(storePath, store);
|
sessionId = entry.sessionId;
|
||||||
}
|
systemSent = entry.systemSent ?? false;
|
||||||
|
} else {
|
||||||
|
sessionId = crypto.randomUUID();
|
||||||
|
isNewSession = true;
|
||||||
|
systemSent = false;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionCtx: TemplateContext = {
|
sessionStore[sessionKey] = { sessionId, updatedAt: Date.now(), systemSent };
|
||||||
...ctx,
|
await saveSessionStore(storePath, sessionStore);
|
||||||
BodyStripped: bodyStripped ?? ctx.Body,
|
}
|
||||||
SessionId: sessionId,
|
|
||||||
IsNewSession: isNewSession ? "true" : "false",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional prefix injected before Body for templating/command prompts.
|
const sessionCtx: TemplateContext = {
|
||||||
const bodyPrefix = reply?.bodyPrefix
|
...ctx,
|
||||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
BodyStripped: bodyStripped ?? ctx.Body,
|
||||||
: "";
|
SessionId: sessionId,
|
||||||
const prefixedBody = bodyPrefix
|
IsNewSession: isNewSession ? "true" : "false",
|
||||||
? `${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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||||
const allowFrom = cfg.inbound?.allowFrom;
|
const allowFrom = cfg.inbound?.allowFrom;
|
||||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||||
if (!allowFrom.includes(from)) {
|
// Support "*" as wildcard to allow all senders
|
||||||
logVerbose(
|
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
||||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
logVerbose(
|
||||||
);
|
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
||||||
return undefined;
|
);
|
||||||
}
|
cleanupTyping();
|
||||||
}
|
return undefined;
|
||||||
if (!reply) {
|
}
|
||||||
logVerbose("No inbound.reply configured; skipping auto-reply");
|
}
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reply.mode === "text" && reply.text) {
|
await startTypingLoop();
|
||||||
await onReplyStart();
|
|
||||||
logVerbose("Using text auto-reply from config");
|
|
||||||
return {
|
|
||||||
text: applyTemplate(reply.text, templatingCtx),
|
|
||||||
mediaUrl: reply.mediaUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reply.mode === "command" && reply.command?.length) {
|
// Optional prefix injected before Body for templating/command prompts.
|
||||||
await onReplyStart();
|
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
|
||||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
const isFirstTurnInSession = isNewSession || !systemSent;
|
||||||
const templatePrefix = reply.template
|
const sessionIntro =
|
||||||
? applyTemplate(reply.template, templatingCtx)
|
isFirstTurnInSession && sessionCfg?.sessionIntro
|
||||||
: "";
|
? applyTemplate(sessionCfg.sessionIntro, sessionCtx)
|
||||||
if (templatePrefix && argv.length > 0) {
|
: "";
|
||||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
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.
|
const prefixedBody =
|
||||||
// We inject the flags only when the user points at the `claude` binary and has opted in via config,
|
transcribedText && reply?.mode === "command"
|
||||||
// so existing custom argv or non-Claude commands remain untouched.
|
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
|
||||||
if (
|
.filter(Boolean)
|
||||||
reply.claudeOutputFormat &&
|
.join("\n\n")
|
||||||
argv.length > 0 &&
|
: prefixedBodyBase;
|
||||||
path.basename(argv[0]) === CLAUDE_BIN
|
const mediaNote = ctx.MediaPath?.length
|
||||||
) {
|
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
|
||||||
const hasOutputFormat = argv.some(
|
: undefined;
|
||||||
(part) =>
|
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
|
||||||
part === "--output-format" || part.startsWith("--output-format="),
|
const mediaReplyHint =
|
||||||
);
|
mediaNote && reply?.mode === "command"
|
||||||
// Keep the final argument as the prompt/body; insert options just before it.
|
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
: undefined;
|
||||||
if (!hasOutputFormat) {
|
const commandBody = mediaNote
|
||||||
argv = [
|
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||||
...argv.slice(0, insertBeforeBody),
|
.filter(Boolean)
|
||||||
"--output-format",
|
.join("\n")
|
||||||
reply.claudeOutputFormat,
|
.trim()
|
||||||
...argv.slice(insertBeforeBody),
|
: prefixedBody;
|
||||||
];
|
const templatingCtx: TemplateContext = {
|
||||||
}
|
...sessionCtx,
|
||||||
const hasPrintFlag = argv.some(
|
Body: commandBody,
|
||||||
(part) => part === "-p" || part === "--print",
|
BodyStripped: commandBody,
|
||||||
);
|
};
|
||||||
if (!hasPrintFlag) {
|
if (!reply) {
|
||||||
const insertIdx = Math.max(argv.length - 1, 0);
|
logVerbose("No inbound.reply configured; skipping auto-reply");
|
||||||
argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)];
|
cleanupTyping();
|
||||||
}
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject session args if configured (use resume for existing, session-id for new)
|
if (reply.mode === "text" && reply.text) {
|
||||||
if (reply.session) {
|
await onReplyStart();
|
||||||
const sessionArgList = (
|
logVerbose("Using text auto-reply from config");
|
||||||
isNewSession
|
const result = {
|
||||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
text: applyTemplate(reply.text, templatingCtx),
|
||||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
mediaUrl: reply.mediaUrl,
|
||||||
).map((part) => applyTemplate(part, templatingCtx));
|
};
|
||||||
if (sessionArgList.length) {
|
cleanupTyping();
|
||||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
return result;
|
||||||
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)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logError(
|
|
||||||
`Command auto-reply failed after ${elapsed}ms: ${String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 & {
|
type TwilioLikeClient = TwilioRequester & {
|
||||||
messages: {
|
messages: {
|
||||||
create: (opts: {
|
create: (opts: {
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
body: string;
|
body: string;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function autoReplyIfConfigured(
|
export async function autoReplyIfConfigured(
|
||||||
client: TwilioLikeClient,
|
client: TwilioLikeClient,
|
||||||
message: MessageInstance,
|
message: MessageInstance,
|
||||||
configOverride?: WarelayConfig,
|
configOverride?: WarelayConfig,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
Body: message.body ?? undefined,
|
Body: message.body ?? undefined,
|
||||||
From: message.from ?? undefined,
|
From: message.from ?? undefined,
|
||||||
To: message.to ?? undefined,
|
To: message.to ?? undefined,
|
||||||
MessageSid: message.sid,
|
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(
|
// Optional audio transcription before building reply.
|
||||||
ctx,
|
const mediaField = (message as { media?: unknown }).media;
|
||||||
{
|
const mediaItems = Array.isArray(mediaField) ? mediaField : [];
|
||||||
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
if (cfg.inbound?.transcribeAudio && mediaItems.length) {
|
||||||
},
|
const media = mediaItems[0];
|
||||||
configOverride,
|
const contentType = (media as { contentType?: string }).contentType;
|
||||||
);
|
if (contentType?.startsWith("audio")) {
|
||||||
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) return;
|
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 replyResult = await getReplyFromConfig(
|
||||||
const replyTo = message.from;
|
ctx,
|
||||||
if (!replyFrom || !replyTo) {
|
{
|
||||||
if (isVerbose())
|
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
||||||
console.error(
|
},
|
||||||
"Skipping auto-reply: missing to/from on inbound message",
|
cfg,
|
||||||
ctx,
|
);
|
||||||
);
|
if (
|
||||||
return;
|
!replyResult ||
|
||||||
}
|
(!replyResult.text &&
|
||||||
|
!replyResult.mediaUrl &&
|
||||||
|
!replyResult.mediaUrls?.length)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
if (replyResult.text) {
|
const replyFrom = message.to;
|
||||||
logVerbose(
|
const replyTo = message.from;
|
||||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
if (!replyFrom || !replyTo) {
|
||||||
);
|
if (isVerbose())
|
||||||
} else {
|
console.error(
|
||||||
logVerbose(
|
"Skipping auto-reply: missing to/from on inbound message",
|
||||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
ctx,
|
||||||
);
|
);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (replyResult.text) {
|
||||||
let mediaUrl = replyResult.mediaUrl;
|
logVerbose(
|
||||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
||||||
const hosted = await ensureMediaHosted(mediaUrl);
|
);
|
||||||
mediaUrl = hosted.url;
|
} else {
|
||||||
}
|
logVerbose(
|
||||||
await client.messages.create({
|
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
||||||
from: replyFrom,
|
);
|
||||||
to: replyTo,
|
}
|
||||||
body: replyResult.text ?? "",
|
|
||||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
try {
|
||||||
});
|
const mediaList = replyResult.mediaUrls?.length
|
||||||
if (isVerbose()) {
|
? replyResult.mediaUrls
|
||||||
console.log(
|
: replyResult.mediaUrl
|
||||||
info(
|
? [replyResult.mediaUrl]
|
||||||
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`,
|
: [];
|
||||||
),
|
|
||||||
);
|
const sendTwilio = async (body: string, media?: string) => {
|
||||||
}
|
let resolvedMedia = media;
|
||||||
} catch (err) {
|
if (resolvedMedia && !/^https?:\/\//i.test(resolvedMedia)) {
|
||||||
const anyErr = err as {
|
const hosted = await ensureMediaHosted(resolvedMedia);
|
||||||
code?: string | number;
|
resolvedMedia = hosted.url;
|
||||||
message?: unknown;
|
}
|
||||||
moreInfo?: unknown;
|
await client.messages.create({
|
||||||
status?: string | number;
|
from: replyFrom,
|
||||||
response?: { body?: unknown };
|
to: replyTo,
|
||||||
};
|
body,
|
||||||
const { code, status } = anyErr;
|
...(resolvedMedia ? { mediaUrl: [resolvedMedia] } : {}),
|
||||||
const msg =
|
});
|
||||||
typeof anyErr?.message === "string"
|
};
|
||||||
? anyErr.message
|
|
||||||
: (anyErr?.message ?? err);
|
if (mediaList.length === 0) {
|
||||||
runtime.error(
|
await sendTwilio(replyResult.text ?? "");
|
||||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
} else {
|
||||||
);
|
// First media with body (if any), then remaining as separate media-only sends.
|
||||||
if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`);
|
await sendTwilio(replyResult.text ?? "", mediaList[0]);
|
||||||
const responseBody = anyErr?.response?.body;
|
for (const extra of mediaList.slice(1)) {
|
||||||
if (responseBody) {
|
await sendTwilio("", extra);
|
||||||
runtime.error("Response body:");
|
}
|
||||||
runtime.error(JSON.stringify(responseBody, null, 2));
|
}
|
||||||
}
|
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 = {
|
export type MsgContext = {
|
||||||
Body?: string;
|
Body?: string;
|
||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
MessageSid?: string;
|
MessageSid?: string;
|
||||||
MediaPath?: string;
|
MediaPath?: string;
|
||||||
MediaUrl?: string;
|
MediaUrl?: string;
|
||||||
MediaType?: string;
|
MediaType?: string;
|
||||||
|
Transcript?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateContext = MsgContext & {
|
export type TemplateContext = MsgContext & {
|
||||||
BodyStripped?: string;
|
BodyStripped?: string;
|
||||||
SessionId?: string;
|
SessionId?: string;
|
||||||
IsNewSession?: string;
|
IsNewSession?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||||
export function applyTemplate(str: string, ctx: TemplateContext) {
|
export function applyTemplate(str: string, ctx: TemplateContext) {
|
||||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||||
const value = (ctx as Record<string, unknown>)[key];
|
const value = (ctx as Record<string, unknown>)[key];
|
||||||
return value == null ? "" : String(value);
|
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 { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
||||||
import { ensureMediaHosted } from "../media/host.js";
|
import { ensureMediaHosted } from "../media/host.js";
|
||||||
import {
|
import {
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
} from "../providers/web/index.js";
|
} from "../providers/web/index.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { createClient } from "../twilio/client.js";
|
import { createClient } from "../twilio/client.js";
|
||||||
@@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js";
|
|||||||
import { waitForever } from "./wait.js";
|
import { waitForever } from "./wait.js";
|
||||||
|
|
||||||
export type CliDeps = {
|
export type CliDeps = {
|
||||||
sendMessage: typeof sendMessage;
|
sendMessage: typeof sendMessage;
|
||||||
sendMessageWeb: typeof sendMessageWeb;
|
sendMessageWeb: typeof sendMessageWeb;
|
||||||
waitForFinalStatus: typeof waitForFinalStatus;
|
waitForFinalStatus: typeof waitForFinalStatus;
|
||||||
assertProvider: typeof assertProvider;
|
assertProvider: typeof assertProvider;
|
||||||
createClient?: typeof createClient;
|
createClient?: typeof createClient;
|
||||||
monitorTwilio: typeof monitorTwilio;
|
monitorTwilio: typeof monitorTwilio;
|
||||||
listRecentMessages: typeof listRecentMessages;
|
listRecentMessages: typeof listRecentMessages;
|
||||||
ensurePortAvailable: typeof ensurePortAvailable;
|
ensurePortAvailable: typeof ensurePortAvailable;
|
||||||
startWebhook: typeof startWebhook;
|
startWebhook: typeof startWebhook;
|
||||||
waitForever: typeof waitForever;
|
waitForever: typeof waitForever;
|
||||||
ensureBinary: typeof ensureBinary;
|
ensureBinary: typeof ensureBinary;
|
||||||
ensureFunnel: typeof ensureFunnel;
|
ensureFunnel: typeof ensureFunnel;
|
||||||
getTailnetHostname: typeof getTailnetHostname;
|
getTailnetHostname: typeof getTailnetHostname;
|
||||||
readEnv: typeof readEnv;
|
readEnv: typeof readEnv;
|
||||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||||
updateWebhook: typeof updateWebhook;
|
updateWebhook: typeof updateWebhook;
|
||||||
handlePortError: typeof handlePortError;
|
handlePortError: typeof handlePortError;
|
||||||
monitorWebProvider: typeof monitorWebProvider;
|
monitorWebProvider: typeof monitorWebProvider;
|
||||||
resolveTwilioMediaUrl: (
|
resolveTwilioMediaUrl: (
|
||||||
source: string,
|
source: string,
|
||||||
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function monitorTwilio(
|
export async function monitorTwilio(
|
||||||
intervalSeconds: number,
|
intervalSeconds: number,
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
maxIterations = Infinity,
|
maxIterations = Infinity,
|
||||||
) {
|
) {
|
||||||
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
||||||
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
||||||
client: clientOverride,
|
client: clientOverride,
|
||||||
maxIterations,
|
maxIterations,
|
||||||
deps: {
|
deps: {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
},
|
},
|
||||||
runtime: defaultRuntime,
|
runtime: defaultRuntime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultDeps(): CliDeps {
|
export function createDefaultDeps(): CliDeps {
|
||||||
// Default dependency bundle used by CLI commands and tests.
|
// Default dependency bundle used by CLI commands and tests.
|
||||||
return {
|
return {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
waitForFinalStatus,
|
waitForFinalStatus,
|
||||||
assertProvider,
|
assertProvider,
|
||||||
createClient,
|
createClient,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
startWebhook,
|
startWebhook,
|
||||||
waitForever,
|
waitForever,
|
||||||
ensureBinary,
|
ensureBinary,
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
readEnv,
|
readEnv,
|
||||||
findWhatsappSenderSid,
|
findWhatsappSenderSid,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
||||||
if (/^https?:\/\//i.test(source)) return source;
|
if (/^https?:\/\//i.test(source)) return source;
|
||||||
const hosted = await ensureMediaHosted(source, {
|
const hosted = await ensureMediaHosted(source, {
|
||||||
startServer: serveMedia,
|
startServer: serveMedia,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
return hosted.url;
|
return hosted.url;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
||||||
// Log the configured Twilio sender for clarity in CLI output.
|
// Log the configured Twilio sender for clarity in CLI output.
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { logWebSelfId };
|
export { logWebSelfId };
|
||||||
+92
-52
@@ -14,11 +14,11 @@ const waitForever = vi.fn();
|
|||||||
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||||
@@ -27,63 +27,103 @@ vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
|
|||||||
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
||||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||||
vi.mock("../provider-web.js", () => ({
|
vi.mock("../provider-web.js", () => ({
|
||||||
loginWeb,
|
loginWeb,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
}));
|
}));
|
||||||
vi.mock("./deps.js", () => ({
|
vi.mock("./deps.js", () => ({
|
||||||
createDefaultDeps: () => ({ waitForever }),
|
createDefaultDeps: () => ({ waitForever }),
|
||||||
logTwilioFrom,
|
logTwilioFrom,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
}));
|
}));
|
||||||
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
||||||
|
|
||||||
const { buildProgram } = await import("./program.js");
|
const { buildProgram } = await import("./program.js");
|
||||||
|
|
||||||
describe("cli program", () => {
|
describe("cli program", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs send with required options", async () => {
|
it("runs send with required options", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
expect(sendCommand).toHaveBeenCalled();
|
expect(sendCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid relay provider", async () => {
|
it("rejects invalid relay provider", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||||
).rejects.toThrow("exit");
|
).rejects.toThrow("exit");
|
||||||
expect(runtime.error).toHaveBeenCalledWith(
|
expect(runtime.error).toHaveBeenCalledWith(
|
||||||
"--provider must be auto, web, or twilio",
|
"--provider must be auto, web, or twilio",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to twilio when web relay fails", async () => {
|
it("falls back to twilio when web relay fails", async () => {
|
||||||
pickProvider.mockResolvedValue("web");
|
pickProvider.mockResolvedValue("web");
|
||||||
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(
|
await expect(
|
||||||
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
program.parseAsync(
|
||||||
{ from: "user" },
|
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||||
);
|
{ from: "user" },
|
||||||
expect(logWebSelfId).toHaveBeenCalled();
|
),
|
||||||
expect(ensureTwilioEnv).toHaveBeenCalled();
|
).rejects.toThrow("exit");
|
||||||
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
|
expect(logWebSelfId).toHaveBeenCalled();
|
||||||
});
|
expect(ensureTwilioEnv).not.toHaveBeenCalled();
|
||||||
|
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("runs relay tmux attach command", async () => {
|
it("runs relay tmux attach command", async () => {
|
||||||
const program = buildProgram();
|
const originalIsTTY = process.stdout.isTTY;
|
||||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
true;
|
||||||
"pnpm warelay relay --verbose",
|
|
||||||
true,
|
const program = buildProgram();
|
||||||
false,
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+624
-284
@@ -3,333 +3,673 @@ import { Command } from "commander";
|
|||||||
import { sendCommand } from "../commands/send.js";
|
import { sendCommand } from "../commands/send.js";
|
||||||
import { statusCommand } from "../commands/status.js";
|
import { statusCommand } from "../commands/status.js";
|
||||||
import { webhookCommand } from "../commands/webhook.js";
|
import { webhookCommand } from "../commands/webhook.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import { ensureTwilioEnv } from "../env.js";
|
import { ensureTwilioEnv } from "../env.js";
|
||||||
import { danger, info, setVerbose, setYes, warn } from "../globals.js";
|
import { danger, info, setVerbose, setYes } from "../globals.js";
|
||||||
import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js";
|
import { getResolvedLoggerSettings } from "../logging.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
|
||||||
import type { Provider } from "../utils.js";
|
|
||||||
import {
|
import {
|
||||||
createDefaultDeps,
|
loginWeb,
|
||||||
logTwilioFrom,
|
logoutWeb,
|
||||||
logWebSelfId,
|
monitorWebProvider,
|
||||||
monitorTwilio,
|
pickProvider,
|
||||||
|
resolveHeartbeatRecipients,
|
||||||
|
runWebHeartbeatOnce,
|
||||||
|
type WebMonitorTuning,
|
||||||
|
} from "../provider-web.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js";
|
||||||
|
import type { Provider } from "../utils.js";
|
||||||
|
import { VERSION } from "../version.js";
|
||||||
|
import {
|
||||||
|
resolveHeartbeatSeconds,
|
||||||
|
resolveReconnectPolicy,
|
||||||
|
} from "../web/reconnect.js";
|
||||||
|
import {
|
||||||
|
createDefaultDeps,
|
||||||
|
logTwilioFrom,
|
||||||
|
logWebSelfId,
|
||||||
|
monitorTwilio,
|
||||||
} from "./deps.js";
|
} from "./deps.js";
|
||||||
import { spawnRelayTmux } from "./relay_tmux.js";
|
import { spawnRelayTmux } from "./relay_tmux.js";
|
||||||
|
|
||||||
export function buildProgram() {
|
export function buildProgram() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const PROGRAM_VERSION = "0.1.3";
|
const PROGRAM_VERSION = VERSION;
|
||||||
const TAGLINE =
|
const TAGLINE =
|
||||||
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("warelay")
|
.name("warelay")
|
||||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||||
.version(PROGRAM_VERSION);
|
.version(PROGRAM_VERSION);
|
||||||
|
|
||||||
const formatIntroLine = (version: string, rich = true) => {
|
const formatIntroLine = (version: string, rich = true) => {
|
||||||
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
||||||
return rich && chalk.level > 0
|
return rich && chalk.level > 0
|
||||||
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
||||||
: base;
|
: base;
|
||||||
};
|
};
|
||||||
|
|
||||||
program.configureHelp({
|
program.configureHelp({
|
||||||
optionTerm: (option) => chalk.yellow(option.flags),
|
optionTerm: (option) => chalk.yellow(option.flags),
|
||||||
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
||||||
});
|
});
|
||||||
|
|
||||||
program.configureOutput({
|
program.configureOutput({
|
||||||
writeOut: (str) => {
|
writeOut: (str) => {
|
||||||
const colored = str
|
const colored = str
|
||||||
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
||||||
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
||||||
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
||||||
process.stdout.write(colored);
|
process.stdout.write(colored);
|
||||||
},
|
},
|
||||||
writeErr: (str) => process.stderr.write(str),
|
writeErr: (str) => process.stderr.write(str),
|
||||||
outputError: (str, write) => write(chalk.red(str)),
|
outputError: (str, write) => write(chalk.red(str)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
||||||
console.log(formatIntroLine(PROGRAM_VERSION));
|
console.log(formatIntroLine(PROGRAM_VERSION));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||||
const examples = [
|
const examples = [
|
||||||
[
|
[
|
||||||
"warelay login --verbose",
|
"warelay login --verbose",
|
||||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
||||||
"Send via your web session and print JSON result.",
|
"Send via your web session and print JSON result.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
||||||
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
||||||
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay status --limit 10 --lookback 60 --json",
|
"warelay status --limit 10 --lookback 60 --json",
|
||||||
"Show last 10 messages from the past hour as JSON.",
|
"Show last 10 messages from the past hour as JSON.",
|
||||||
],
|
],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const fmtExamples = examples
|
const fmtExamples = examples
|
||||||
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
program.addHelpText(
|
program.addHelpText(
|
||||||
"afterAll",
|
"afterAll",
|
||||||
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("login")
|
.command("login")
|
||||||
.description("Link your personal WhatsApp via QR (web provider)")
|
.description("Link your personal WhatsApp via QR (web provider)")
|
||||||
.option("--verbose", "Verbose connection logs", false)
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
try {
|
try {
|
||||||
await loginWeb(Boolean(opts.verbose));
|
await loginWeb(Boolean(opts.verbose));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("send")
|
.command("logout")
|
||||||
.description("Send a WhatsApp message")
|
.description("Clear cached WhatsApp Web credentials")
|
||||||
.requiredOption(
|
.action(async () => {
|
||||||
"-t, --to <number>",
|
try {
|
||||||
"Recipient number in E.164 (e.g. +15551234567)",
|
await logoutWeb(defaultRuntime);
|
||||||
)
|
} catch (err) {
|
||||||
.requiredOption("-m, --message <text>", "Message body")
|
defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
|
||||||
.option(
|
defaultRuntime.exit(1);
|
||||||
"--media <path-or-url>",
|
}
|
||||||
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
});
|
||||||
)
|
|
||||||
.option(
|
program
|
||||||
"--serve-media",
|
.command("send")
|
||||||
"For Twilio: start a temporary media server if webhook is not running",
|
.description("Send a WhatsApp message")
|
||||||
false,
|
.requiredOption(
|
||||||
)
|
"-t, --to <number>",
|
||||||
.option(
|
"Recipient number in E.164 (e.g. +15551234567)",
|
||||||
"-w, --wait <seconds>",
|
)
|
||||||
"Wait for delivery status (0 to skip)",
|
.requiredOption("-m, --message <text>", "Message body")
|
||||||
"20",
|
.option(
|
||||||
)
|
"--media <path-or-url>",
|
||||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
||||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
)
|
||||||
.option("--dry-run", "Print payload and skip sending", false)
|
.option(
|
||||||
.option("--json", "Output result as JSON", false)
|
"--serve-media",
|
||||||
.option("--verbose", "Verbose logging", false)
|
"For Twilio: start a temporary media server if webhook is not running",
|
||||||
.addHelpText(
|
false,
|
||||||
"after",
|
)
|
||||||
`
|
.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:
|
Examples:
|
||||||
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
|
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" --wait 0 # fire-and-forget
|
||||||
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
|
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
|
||||||
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await sendCommand(opts, deps, defaultRuntime);
|
await sendCommand(opts, deps, defaultRuntime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay")
|
.command("heartbeat")
|
||||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
.description(
|
||||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
"Trigger a heartbeat or manual send once (web or twilio, no tmux)",
|
||||||
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
)
|
||||||
.option(
|
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||||
"-l, --lookback <minutes>",
|
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
||||||
"Initial lookback window for twilio mode",
|
.option(
|
||||||
"5",
|
"--session-id <id>",
|
||||||
)
|
"Force a session id for this heartbeat (resumes a specific Claude session)",
|
||||||
.option("--verbose", "Verbose logging", false)
|
)
|
||||||
.addHelpText(
|
.option(
|
||||||
"after",
|
"--all",
|
||||||
`
|
"Send heartbeat to all active sessions (or allowFrom entries when none)",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--message <text>",
|
||||||
|
"Send a custom message instead of the heartbeat probe (web or twilio provider)",
|
||||||
|
)
|
||||||
|
.option("--body <text>", "Alias for --message")
|
||||||
|
.option("--dry-run", "Print the resolved payload without sending", 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 --message "Ping" --provider twilio
|
||||||
|
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", "twilio"].includes(providerPref)) {
|
||||||
|
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideBody =
|
||||||
|
(opts.message as string | undefined) ||
|
||||||
|
(opts.body as string | undefined) ||
|
||||||
|
undefined;
|
||||||
|
const dryRun = Boolean(opts.dryRun);
|
||||||
|
|
||||||
|
const provider =
|
||||||
|
providerPref === "twilio"
|
||||||
|
? "twilio"
|
||||||
|
: await pickProvider(providerPref as "auto" | "web");
|
||||||
|
if (provider === "twilio") ensureTwilioEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const to of recipients) {
|
||||||
|
if (provider === "web") {
|
||||||
|
await runWebHeartbeatOnce({
|
||||||
|
to,
|
||||||
|
verbose: Boolean(opts.verbose),
|
||||||
|
runtime: defaultRuntime,
|
||||||
|
sessionId: opts.sessionId,
|
||||||
|
overrideBody,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await runTwilioHeartbeatOnce({
|
||||||
|
to,
|
||||||
|
verbose: Boolean(opts.verbose),
|
||||||
|
runtime: defaultRuntime,
|
||||||
|
overrideBody,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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:
|
Examples:
|
||||||
warelay relay # auto: web if logged-in, else twilio poll
|
warelay relay # auto: web if logged-in, else twilio poll
|
||||||
warelay relay --provider web # force personal web session
|
warelay relay --provider web # force personal web session
|
||||||
warelay relay --provider twilio # force twilio poll
|
warelay relay --provider twilio # force twilio poll
|
||||||
warelay relay --provider twilio --interval 2 --lookback 30
|
warelay relay --provider twilio --interval 2 --lookback 30
|
||||||
|
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const providerPref = String(opts.provider ?? "auto");
|
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
|
||||||
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
|
||||||
defaultRuntime.error("--provider must be auto, web, or twilio");
|
const providerPref = String(opts.provider ?? "auto");
|
||||||
defaultRuntime.exit(1);
|
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||||
}
|
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||||
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
defaultRuntime.exit(1);
|
||||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
}
|
||||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
||||||
defaultRuntime.error("Interval must be a positive integer");
|
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||||
defaultRuntime.exit(1);
|
const webHeartbeat =
|
||||||
}
|
opts.webHeartbeat !== undefined
|
||||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
? Number.parseInt(String(opts.webHeartbeat), 10)
|
||||||
defaultRuntime.error("Lookback must be >= 0 minutes");
|
: undefined;
|
||||||
defaultRuntime.exit(1);
|
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") {
|
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureTwilioEnv();
|
if (provider === "web") {
|
||||||
logTwilioFrom();
|
logWebSelfId(defaultRuntime, true);
|
||||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
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
|
ensureTwilioEnv();
|
||||||
.command("status")
|
logTwilioFrom();
|
||||||
.description("Show recent WhatsApp messages (sent and received)")
|
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||||
.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)
|
program
|
||||||
.option("--verbose", "Verbose logging", false)
|
.command("relay:heartbeat")
|
||||||
.addHelpText(
|
.description(
|
||||||
"after",
|
"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:
|
Examples:
|
||||||
warelay status # last 20 msgs in past 4h
|
warelay status # last 20 msgs in past 4h
|
||||||
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
||||||
warelay status --json --limit 50 # machine-readable output`,
|
warelay status --json --limit 50 # machine-readable output`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await statusCommand(opts, deps, defaultRuntime);
|
await statusCommand(opts, deps, defaultRuntime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("webhook")
|
.command("webhook")
|
||||||
.description(
|
.description(
|
||||||
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
||||||
)
|
)
|
||||||
.option("-p, --port <port>", "Port to listen on", "42873")
|
.option("-p, --port <port>", "Port to listen on", "42873")
|
||||||
.option("-r, --reply <text>", "Optional auto-reply text")
|
.option("-r, --reply <text>", "Optional auto-reply text")
|
||||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||||
.option(
|
.option(
|
||||||
"--ingress <mode>",
|
"--ingress <mode>",
|
||||||
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
)
|
)
|
||||||
.option("--verbose", "Log inbound and auto-replies", false)
|
.option("--verbose", "Log inbound and auto-replies", false)
|
||||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||||
.option("--dry-run", "Print planned actions without starting server", false)
|
.option("--dry-run", "Print planned actions without starting server", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay webhook # ingress=tailscale (funnel + Twilio update)
|
warelay webhook # ingress=tailscale (funnel + Twilio update)
|
||||||
warelay webhook --ingress none # local-only server (no funnel / no 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 --port 45000 # pick a high, less-colliding port
|
||||||
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
||||||
)
|
)
|
||||||
// istanbul ignore next
|
// istanbul ignore next
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
setYes(Boolean(opts.yes));
|
setYes(Boolean(opts.yes));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
info("Webhook dry-run complete; no server started."),
|
info("Webhook dry-run complete; no server started."),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log("\n👋 Webhook stopped");
|
console.log("\n👋 Webhook stopped");
|
||||||
defaultRuntime.exit(0);
|
defaultRuntime.exit(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await deps.waitForever();
|
await deps.waitForever();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay:tmux")
|
.command("relay:tmux")
|
||||||
.description(
|
.description(
|
||||||
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
const session = await spawnRelayTmux(
|
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||||
"pnpm warelay relay --verbose",
|
const session = await spawnRelayTmux(
|
||||||
true,
|
"pnpm warelay relay --verbose",
|
||||||
);
|
shouldAttach,
|
||||||
defaultRuntime.log(
|
);
|
||||||
info(
|
defaultRuntime.log(
|
||||||
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
info(
|
||||||
),
|
shouldAttach
|
||||||
);
|
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`
|
||||||
} catch (err) {
|
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose"); attach manually with "tmux attach -t ${session}"`,
|
||||||
defaultRuntime.error(
|
),
|
||||||
danger(`Failed to start relay tmux session: ${String(err)}`),
|
);
|
||||||
);
|
} catch (err) {
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.error(
|
||||||
}
|
danger(`Failed to start relay tmux session: ${String(err)}`),
|
||||||
});
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay:tmux:attach")
|
.command("relay:tmux:attach")
|
||||||
.description(
|
.description(
|
||||||
"Attach to the existing warelay-relay tmux session (no restart)",
|
"Attach to the existing warelay-relay tmux session (no restart)",
|
||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
if (!process.stdout.isTTY) {
|
||||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
defaultRuntime.error(
|
||||||
} catch (err) {
|
danger(
|
||||||
defaultRuntime.error(
|
"Cannot attach: stdout is not a TTY. Run this in a terminal or use 'tmux attach -t warelay-relay' manually.",
|
||||||
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
),
|
||||||
);
|
);
|
||||||
defaultRuntime.exit(1);
|
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";
|
import { isYes, setVerbose, setYes } from "../globals.js";
|
||||||
|
|
||||||
vi.mock("node:readline/promises", () => {
|
vi.mock("node:readline/promises", () => {
|
||||||
const question = vi.fn<[], Promise<string>>();
|
const question = vi.fn<[], Promise<string>>();
|
||||||
const close = vi.fn();
|
const close = vi.fn();
|
||||||
const createInterface = vi.fn(() => ({ question, close }));
|
const createInterface = vi.fn(() => ({ question, close }));
|
||||||
return { default: { createInterface } };
|
return { default: { createInterface } };
|
||||||
});
|
});
|
||||||
|
|
||||||
type ReadlineMock = {
|
type ReadlineMock = {
|
||||||
default: {
|
default: {
|
||||||
createInterface: () => {
|
createInterface: () => {
|
||||||
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
||||||
close: ReturnType<typeof vi.fn>;
|
close: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const { promptYesNo } = await import("./prompt.js");
|
const { promptYesNo } = await import("./prompt.js");
|
||||||
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
||||||
|
|
||||||
describe("promptYesNo", () => {
|
describe("promptYesNo", () => {
|
||||||
it("returns true when global --yes is set", async () => {
|
it("returns true when global --yes is set", async () => {
|
||||||
setYes(true);
|
setYes(true);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
const result = await promptYesNo("Continue?");
|
const result = await promptYesNo("Continue?");
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(isYes()).toBe(true);
|
expect(isYes()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks the question and respects default", async () => {
|
it("asks the question and respects default", async () => {
|
||||||
setYes(false);
|
setYes(false);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
const { question: questionMock } = readline.default.createInterface();
|
const { question: questionMock } = readline.default.createInterface();
|
||||||
questionMock.mockResolvedValueOnce("");
|
questionMock.mockResolvedValueOnce("");
|
||||||
const resultDefaultYes = await promptYesNo("Continue?", true);
|
const resultDefaultYes = await promptYesNo("Continue?", true);
|
||||||
expect(resultDefaultYes).toBe(true);
|
expect(resultDefaultYes).toBe(true);
|
||||||
|
|
||||||
questionMock.mockResolvedValueOnce("n");
|
questionMock.mockResolvedValueOnce("n");
|
||||||
const resultNo = await promptYesNo("Continue?", true);
|
const resultNo = await promptYesNo("Continue?", true);
|
||||||
expect(resultNo).toBe(false);
|
expect(resultNo).toBe(false);
|
||||||
|
|
||||||
questionMock.mockResolvedValueOnce("y");
|
questionMock.mockResolvedValueOnce("y");
|
||||||
const resultYes = await promptYesNo("Continue?", false);
|
const resultYes = await promptYesNo("Continue?", false);
|
||||||
expect(resultYes).toBe(true);
|
expect(resultYes).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+13
-13
@@ -4,18 +4,18 @@ import readline from "node:readline/promises";
|
|||||||
import { isVerbose, isYes } from "../globals.js";
|
import { isVerbose, isYes } from "../globals.js";
|
||||||
|
|
||||||
export async function promptYesNo(
|
export async function promptYesNo(
|
||||||
question: string,
|
question: string,
|
||||||
defaultYes = false,
|
defaultYes = false,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
||||||
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
||||||
if (isYes()) return true;
|
if (isYes()) return true;
|
||||||
const rl = readline.createInterface({ input, output });
|
const rl = readline.createInterface({ input, output });
|
||||||
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||||
const answer = (await rl.question(`${question}${suffix}`))
|
const answer = (await rl.question(`${question}${suffix}`))
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
rl.close();
|
rl.close();
|
||||||
if (!answer) return defaultYes;
|
if (!answer) return defaultYes;
|
||||||
return answer.startsWith("y");
|
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.
|
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
|
||||||
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
return {
|
return {
|
||||||
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
||||||
pickProvider: vi.fn().mockResolvedValue("web"),
|
pickProvider: vi.fn().mockResolvedValue("web"),
|
||||||
logWebSelfId: vi.fn(),
|
logWebSelfId: vi.fn(),
|
||||||
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../provider-web.js", () => ({
|
vi.mock("../provider-web.js", () => ({
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../twilio/monitor.js", () => ({
|
vi.mock("../twilio/monitor.js", () => ({
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { buildProgram } from "./program.js";
|
import { buildProgram } from "./program.js";
|
||||||
|
|
||||||
describe("CLI relay command (e2e-ish)", () => {
|
describe("CLI relay command (e2e-ish)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs relay in web mode without crashing", async () => {
|
it("runs relay in web mode without crashing", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
program.exitOverride(); // throw instead of exiting process on error
|
program.exitOverride(); // throw instead of exiting process on error
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
||||||
).resolves.toBeInstanceOf(Object);
|
).resolves.toBeInstanceOf(Object);
|
||||||
|
|
||||||
expect(pickProvider).toHaveBeenCalledWith("web");
|
expect(pickProvider).toHaveBeenCalledWith("web");
|
||||||
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
||||||
expect(monitorWebProvider).toHaveBeenCalledWith(false);
|
expect(monitorWebProvider).toHaveBeenCalledTimes(1);
|
||||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
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";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("node:child_process", () => {
|
vi.mock("node:child_process", () => {
|
||||||
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
||||||
const proc = new EventEmitter() as EventEmitter & {
|
const proc = new EventEmitter() as EventEmitter & {
|
||||||
kill: ReturnType<typeof vi.fn>;
|
kill: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
proc.emit("exit", 0);
|
proc.emit("exit", 0);
|
||||||
});
|
});
|
||||||
proc.kill = vi.fn();
|
proc.kill = vi.fn();
|
||||||
return proc;
|
return proc;
|
||||||
});
|
});
|
||||||
return { spawn };
|
return { spawn };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
||||||
const { spawn } = await import("node:child_process");
|
const { spawn } = await import("node:child_process");
|
||||||
|
|
||||||
describe("spawnRelayTmux", () => {
|
describe("spawnRelayTmux", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kills old session, starts new one, and attaches", async () => {
|
it("kills old session, starts new one, and attaches", async () => {
|
||||||
const session = await spawnRelayTmux("echo hi", true, true);
|
const session = await spawnRelayTmux("echo hi", true, true);
|
||||||
expect(session).toBe("warelay-relay");
|
expect(session).toBe("warelay-relay");
|
||||||
const spawnMock = spawn as unknown as vi.Mock;
|
const spawnMock = spawn as unknown as vi.Mock;
|
||||||
expect(spawnMock.mock.calls.length).toBe(3);
|
expect(spawnMock.mock.calls.length).toBe(3);
|
||||||
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
||||||
expect(calls[0][0]).toBe("tmux"); // kill-session
|
expect(calls[0][0]).toBe("tmux"); // kill-session
|
||||||
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
||||||
expect(calls[2][1][0]).toBe("attach-session");
|
expect(calls[2][1][0]).toBe("attach-session");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can skip attach", async () => {
|
it("can skip attach", async () => {
|
||||||
await spawnRelayTmux("echo hi", false, true);
|
await spawnRelayTmux("echo hi", false, true);
|
||||||
const spawnMock = spawn as unknown as vi.Mock;
|
const spawnMock = spawn as unknown as vi.Mock;
|
||||||
const hasAttach = spawnMock.mock.calls.some(
|
const hasAttach = spawnMock.mock.calls.some(
|
||||||
(c) =>
|
(c) =>
|
||||||
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
||||||
);
|
);
|
||||||
expect(hasAttach).toBe(false);
|
expect(hasAttach).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+38
-38
@@ -3,48 +3,48 @@ import { spawn } from "node:child_process";
|
|||||||
const SESSION = "warelay-relay";
|
const SESSION = "warelay-relay";
|
||||||
|
|
||||||
export async function spawnRelayTmux(
|
export async function spawnRelayTmux(
|
||||||
cmd = "pnpm warelay relay --verbose",
|
cmd = "pnpm warelay relay --verbose",
|
||||||
attach = true,
|
attach = true,
|
||||||
restart = true,
|
restart = true,
|
||||||
) {
|
) {
|
||||||
if (restart) {
|
if (restart) {
|
||||||
await killSession(SESSION);
|
await killSession(SESSION);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: false,
|
shell: false,
|
||||||
});
|
});
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
else reject(new Error(`tmux exited with code ${code}`));
|
else reject(new Error(`tmux exited with code ${code}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attach) {
|
if (attach) {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: false,
|
shell: false,
|
||||||
});
|
});
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
else reject(new Error(`tmux attach exited with code ${code}`));
|
else reject(new Error(`tmux attach exited with code ${code}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return SESSION;
|
return SESSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function killSession(name: string) {
|
async function killSession(name: string) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const child = spawn("tmux", ["kill-session", "-t", name], {
|
const child = spawn("tmux", ["kill-session", "-t", name], {
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
});
|
});
|
||||||
child.on("exit", () => resolve());
|
child.on("exit", () => resolve());
|
||||||
child.on("error", () => resolve());
|
child.on("error", () => resolve());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+10
-10
@@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { waitForever } from "./wait.js";
|
import { waitForever } from "./wait.js";
|
||||||
|
|
||||||
describe("waitForever", () => {
|
describe("waitForever", () => {
|
||||||
it("creates an unref'ed interval and returns a pending promise", () => {
|
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||||
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
||||||
const promise = waitForever();
|
const promise = waitForever();
|
||||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
1_000_000,
|
1_000_000,
|
||||||
);
|
);
|
||||||
expect(promise).toBeInstanceOf(Promise);
|
expect(promise).toBeInstanceOf(Promise);
|
||||||
setIntervalSpy.mockRestore();
|
setIntervalSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+6
-6
@@ -1,8 +1,8 @@
|
|||||||
export function waitForever() {
|
export function waitForever() {
|
||||||
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
||||||
const interval = setInterval(() => {}, 1_000_000);
|
const interval = setInterval(() => {}, 1_000_000);
|
||||||
interval.unref();
|
interval.unref();
|
||||||
return new Promise<void>(() => {
|
return new Promise<void>(() => {
|
||||||
/* never resolve */
|
/* never resolve */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+126
-126
@@ -5,141 +5,141 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { sendCommand } from "./send.js";
|
import { sendCommand } from "./send.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseDeps = {
|
const baseDeps = {
|
||||||
assertProvider: vi.fn(),
|
assertProvider: vi.fn(),
|
||||||
sendMessageWeb: vi.fn(),
|
sendMessageWeb: vi.fn(),
|
||||||
resolveTwilioMediaUrl: vi.fn(),
|
resolveTwilioMediaUrl: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
waitForFinalStatus: vi.fn(),
|
waitForFinalStatus: vi.fn(),
|
||||||
} as unknown as CliDeps;
|
} as unknown as CliDeps;
|
||||||
|
|
||||||
describe("sendCommand", () => {
|
describe("sendCommand", () => {
|
||||||
it("validates wait and poll", async () => {
|
it("validates wait and poll", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
sendCommand(
|
sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "-1",
|
wait: "-1",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
},
|
},
|
||||||
baseDeps,
|
baseDeps,
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||||
|
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
sendCommand(
|
sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "0",
|
poll: "0",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
},
|
},
|
||||||
baseDeps,
|
baseDeps,
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Poll must be > 0 seconds");
|
).rejects.toThrow("Poll must be > 0 seconds");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles web dry-run and warns on wait", async () => {
|
it("handles web dry-run and warns on wait", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
sendMessageWeb: vi.fn(),
|
sendMessageWeb: vi.fn(),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "5",
|
wait: "5",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "web",
|
provider: "web",
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
media: "pic.jpg",
|
media: "pic.jpg",
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends via web and outputs JSON", async () => {
|
it("sends via web and outputs JSON", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "1",
|
wait: "1",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "web",
|
provider: "web",
|
||||||
json: true,
|
json: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"provider": "web"'),
|
expect.stringContaining('"provider": "web"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports twilio dry-run", async () => {
|
it("supports twilio dry-run", async () => {
|
||||||
const deps = { ...baseDeps } as CliDeps;
|
const deps = { ...baseDeps } as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends via twilio with media and skips wait when zero", async () => {
|
it("sends via twilio with media and skips wait when zero", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||||
waitForFinalStatus: vi.fn(),
|
waitForFinalStatus: vi.fn(),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
media: "pic.jpg",
|
media: "pic.jpg",
|
||||||
serveMedia: true,
|
serveMedia: true,
|
||||||
json: true,
|
json: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||||
serveMedia: true,
|
serveMedia: true,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"provider": "twilio"'),
|
expect.stringContaining('"provider": "twilio"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+99
-99
@@ -4,109 +4,109 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
|
|
||||||
export async function sendCommand(
|
export async function sendCommand(
|
||||||
opts: {
|
opts: {
|
||||||
to: string;
|
to: string;
|
||||||
message: string;
|
message: string;
|
||||||
wait: string;
|
wait: string;
|
||||||
poll: string;
|
poll: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
media?: string;
|
media?: string;
|
||||||
serveMedia?: boolean;
|
serveMedia?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
deps.assertProvider(opts.provider);
|
deps.assertProvider(opts.provider);
|
||||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||||
|
|
||||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||||
throw new Error("Wait must be >= 0 seconds");
|
throw new Error("Wait must be >= 0 seconds");
|
||||||
}
|
}
|
||||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||||
throw new Error("Poll must be > 0 seconds");
|
throw new Error("Poll must be > 0 seconds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.provider === "web") {
|
if (opts.provider === "web") {
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (waitSeconds !== 0) {
|
if (waitSeconds !== 0) {
|
||||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||||
}
|
}
|
||||||
const res = await deps
|
const res = await deps
|
||||||
.sendMessageWeb(opts.to, opts.message, {
|
.sendMessageWeb(opts.to, opts.message, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: opts.media,
|
mediaUrl: opts.media,
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
provider: "web",
|
provider: "web",
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
messageId: res.messageId,
|
messageId: res.messageId,
|
||||||
mediaUrl: opts.media ?? null,
|
mediaUrl: opts.media ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaUrl: string | undefined;
|
let mediaUrl: string | undefined;
|
||||||
if (opts.media) {
|
if (opts.media) {
|
||||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||||
serveMedia: Boolean(opts.serveMedia),
|
serveMedia: Boolean(opts.serveMedia),
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await deps.sendMessage(
|
const result = await deps.sendMessage(
|
||||||
opts.to,
|
opts.to,
|
||||||
opts.message,
|
opts.message,
|
||||||
{ mediaUrl },
|
{ mediaUrl },
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
sid: result?.sid ?? null,
|
sid: result?.sid ?? null,
|
||||||
mediaUrl: mediaUrl ?? null,
|
mediaUrl: mediaUrl ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (waitSeconds === 0) return;
|
if (waitSeconds === 0) return;
|
||||||
await deps.waitForFinalStatus(
|
await deps.waitForFinalStatus(
|
||||||
result.client,
|
result.client,
|
||||||
result.sid,
|
result.sid,
|
||||||
waitSeconds,
|
waitSeconds,
|
||||||
pollSeconds,
|
pollSeconds,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+31
-31
@@ -5,46 +5,46 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { statusCommand } from "./status.js";
|
import { statusCommand } from "./status.js";
|
||||||
|
|
||||||
vi.mock("../twilio/messages.js", () => ({
|
vi.mock("../twilio/messages.js", () => ({
|
||||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
listRecentMessages: vi.fn(),
|
listRecentMessages: vi.fn(),
|
||||||
} as unknown as CliDeps;
|
} as unknown as CliDeps;
|
||||||
|
|
||||||
describe("statusCommand", () => {
|
describe("statusCommand", () => {
|
||||||
it("validates limit and lookback", async () => {
|
it("validates limit and lookback", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||||
).rejects.toThrow("limit must be between 1 and 200");
|
).rejects.toThrow("limit must be between 1 and 200");
|
||||||
await expect(
|
await expect(
|
||||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||||
).rejects.toThrow("lookback must be > 0 minutes");
|
).rejects.toThrow("lookback must be > 0 minutes");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints JSON when requested", async () => {
|
it("prints JSON when requested", async () => {
|
||||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||||
await statusCommand(
|
await statusCommand(
|
||||||
{ limit: "5", lookback: "10", json: true },
|
{ limit: "5", lookback: "10", json: true },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
JSON.stringify([{ sid: "1" }], null, 2),
|
JSON.stringify([{ sid: "1" }], null, 2),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints formatted lines otherwise", async () => {
|
it("prints formatted lines otherwise", async () => {
|
||||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+23
-23
@@ -3,29 +3,29 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { formatMessageLine } from "../twilio/messages.js";
|
import { formatMessageLine } from "../twilio/messages.js";
|
||||||
|
|
||||||
export async function statusCommand(
|
export async function statusCommand(
|
||||||
opts: { limit: string; lookback: string; json?: boolean },
|
opts: { limit: string; lookback: string; json?: boolean },
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
const limit = Number.parseInt(opts.limit, 10);
|
const limit = Number.parseInt(opts.limit, 10);
|
||||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||||
throw new Error("limit must be between 1 and 200");
|
throw new Error("limit must be between 1 and 200");
|
||||||
}
|
}
|
||||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||||
throw new Error("lookback must be > 0 minutes");
|
throw new Error("lookback must be > 0 minutes");
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(JSON.stringify(messages, null, 2));
|
runtime.log(JSON.stringify(messages, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
runtime.log("No messages found in the requested window.");
|
runtime.log("No messages found in the requested window.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
runtime.log(formatMessageLine(m));
|
runtime.log(formatMessageLine(m));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+60
-60
@@ -5,72 +5,72 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { upCommand } from "./up.js";
|
import { upCommand } from "./up.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeDeps = (): CliDeps => ({
|
const makeDeps = (): CliDeps => ({
|
||||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||||
readEnv: vi.fn().mockReturnValue({
|
readEnv: vi.fn().mockReturnValue({
|
||||||
whatsappFrom: "whatsapp:+1555",
|
whatsappFrom: "whatsapp:+1555",
|
||||||
whatsappSenderSid: "WW",
|
whatsappSenderSid: "WW",
|
||||||
}),
|
}),
|
||||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("upCommand", () => {
|
describe("upCommand", () => {
|
||||||
it("throws on invalid port", async () => {
|
it("throws on invalid port", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||||
).rejects.toThrow("Port must be between 1 and 65535");
|
).rejects.toThrow("Port must be between 1 and 65535");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("performs dry run and returns mock data", async () => {
|
it("performs dry run and returns mock data", async () => {
|
||||||
runtime.log.mockClear();
|
runtime.log.mockClear();
|
||||||
const result = await upCommand(
|
const result = await upCommand(
|
||||||
{ port: "42873", path: "/cb", dryRun: true },
|
{ port: "42873", path: "/cb", dryRun: true },
|
||||||
makeDeps(),
|
makeDeps(),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
"[dry-run] would enable funnel on port 42873",
|
"[dry-run] would enable funnel on port 42873",
|
||||||
);
|
);
|
||||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||||
expect(result?.senderSid).toBeUndefined();
|
expect(result?.senderSid).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const res = await upCommand(
|
const res = await upCommand(
|
||||||
{ port: "42873", path: "/hook", verbose: true },
|
{ port: "42873", path: "/hook", verbose: true },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||||
"tailscale",
|
"tailscale",
|
||||||
undefined,
|
undefined,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||||
expect(deps.startWebhook).toHaveBeenCalled();
|
expect(deps.startWebhook).toHaveBeenCalled();
|
||||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
"SID123",
|
"SID123",
|
||||||
"https://tailnet-host/hook",
|
"https://tailnet-host/hook",
|
||||||
"POST",
|
"POST",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||||
// waiter is returned to keep the process alive in real use.
|
// waiter is returned to keep the process alive in real use.
|
||||||
expect(typeof res?.waiter).toBe("function");
|
expect(typeof res?.waiter).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+55
-55
@@ -4,65 +4,65 @@ import { retryAsync } from "../infra/retry.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
export async function upCommand(
|
export async function upCommand(
|
||||||
opts: {
|
opts: {
|
||||||
port: string;
|
port: string;
|
||||||
path: string;
|
path: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||||
) {
|
) {
|
||||||
const port = Number.parseInt(opts.port, 10);
|
const port = Number.parseInt(opts.port, 10);
|
||||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||||
throw new Error("Port must be between 1 and 65535");
|
throw new Error("Port must be between 1 and 65535");
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.ensurePortAvailable(port);
|
await deps.ensurePortAvailable(port);
|
||||||
const env = deps.readEnv(runtime);
|
const env = deps.readEnv(runtime);
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
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 start webhook at path ${opts.path}`);
|
||||||
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
||||||
const publicUrl = `https://dry-run${opts.path}`;
|
const publicUrl = `https://dry-run${opts.path}`;
|
||||||
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
||||||
}
|
}
|
||||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||||
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||||
const host = await deps.getTailnetHostname();
|
const host = await deps.getTailnetHostname();
|
||||||
const publicUrl = `https://${host}${opts.path}`;
|
const publicUrl = `https://${host}${opts.path}`;
|
||||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||||
|
|
||||||
const server = await retryAsync(
|
const server = await retryAsync(
|
||||||
() =>
|
() =>
|
||||||
deps.startWebhook(
|
deps.startWebhook(
|
||||||
port,
|
port,
|
||||||
opts.path,
|
opts.path,
|
||||||
undefined,
|
undefined,
|
||||||
Boolean(opts.verbose),
|
Boolean(opts.verbose),
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
3,
|
3,
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!deps.createClient) {
|
if (!deps.createClient) {
|
||||||
throw new Error("Twilio client dependency missing");
|
throw new Error("Twilio client dependency missing");
|
||||||
}
|
}
|
||||||
const twilioClient = deps.createClient(env);
|
const twilioClient = deps.createClient(env);
|
||||||
const senderSid = await deps.findWhatsappSenderSid(
|
const senderSid = await deps.findWhatsappSenderSid(
|
||||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||||
env.whatsappFrom,
|
env.whatsappFrom,
|
||||||
env.whatsappSenderSid,
|
env.whatsappSenderSid,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||||
|
|
||||||
runtime.log(
|
runtime.log(
|
||||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
"\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";
|
import { webhookCommand } from "./webhook.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("webhookCommand", () => {
|
describe("webhookCommand", () => {
|
||||||
it("throws on invalid port", async () => {
|
it("throws on invalid port", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||||
).rejects.toThrow("Port must be between 1 and 65535");
|
).rejects.toThrow("Port must be between 1 and 65535");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs dry run instead of starting server", async () => {
|
it("logs dry run instead of starting server", async () => {
|
||||||
runtime.log.mockClear();
|
runtime.log.mockClear();
|
||||||
const res = await webhookCommand(
|
const res = await webhookCommand(
|
||||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res).toBeUndefined();
|
expect(res).toBeUndefined();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
"[dry-run] would start webhook on port 42873 path /hook",
|
"[dry-run] would start webhook on port 42873 path /hook",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts webhook when valid", async () => {
|
it("starts webhook when valid", async () => {
|
||||||
const res = await webhookCommand(
|
const res = await webhookCommand(
|
||||||
{
|
{
|
||||||
port: "42873",
|
port: "42873",
|
||||||
path: "/hook",
|
path: "/hook",
|
||||||
reply: "ok",
|
reply: "ok",
|
||||||
verbose: true,
|
verbose: true,
|
||||||
ingress: "none",
|
ingress: "none",
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||||
42873,
|
42873,
|
||||||
"/hook",
|
"/hook",
|
||||||
"ok",
|
"ok",
|
||||||
true,
|
true,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res).toEqual({ server: true });
|
expect(res).toEqual({ server: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+52
-52
@@ -4,60 +4,60 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { upCommand } from "./up.js";
|
import { upCommand } from "./up.js";
|
||||||
|
|
||||||
export async function webhookCommand(
|
export async function webhookCommand(
|
||||||
opts: {
|
opts: {
|
||||||
port: string;
|
port: string;
|
||||||
path: string;
|
path: string;
|
||||||
reply?: string;
|
reply?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
ingress?: "tailscale" | "none";
|
ingress?: "tailscale" | "none";
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
const port = Number.parseInt(opts.port, 10);
|
const port = Number.parseInt(opts.port, 10);
|
||||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||||
throw new Error("Port must be between 1 and 65535");
|
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).
|
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||||
if (ingress === "tailscale") {
|
if (ingress === "tailscale") {
|
||||||
const result = await upCommand(
|
const result = await upCommand(
|
||||||
{
|
{
|
||||||
port: opts.port,
|
port: opts.port,
|
||||||
path: opts.path,
|
path: opts.path,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
yes: opts.yes,
|
yes: opts.yes,
|
||||||
dryRun: opts.dryRun,
|
dryRun: opts.dryRun,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return result.server;
|
return result.server;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local-only webhook (no ingress / no Twilio update).
|
// Local-only webhook (no ingress / no Twilio update).
|
||||||
await deps.ensurePortAvailable(port);
|
await deps.ensurePortAvailable(port);
|
||||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const server = await retryAsync(
|
const server = await retryAsync(
|
||||||
() =>
|
() =>
|
||||||
deps.startWebhook(
|
deps.startWebhook(
|
||||||
port,
|
port,
|
||||||
opts.path,
|
opts.path,
|
||||||
opts.reply,
|
opts.reply,
|
||||||
Boolean(opts.verbose),
|
Boolean(opts.verbose),
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
3,
|
3,
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
+158
-104
@@ -10,124 +10,178 @@ export type ClaudeOutputFormat = "text" | "json" | "stream-json";
|
|||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
export type SessionConfig = {
|
export type SessionConfig = {
|
||||||
scope?: SessionScope;
|
scope?: SessionScope;
|
||||||
resetTriggers?: string[];
|
resetTriggers?: string[];
|
||||||
idleMinutes?: number;
|
idleMinutes?: number;
|
||||||
store?: string;
|
heartbeatIdleMinutes?: number;
|
||||||
sessionArgNew?: string[];
|
store?: string;
|
||||||
sessionArgResume?: string[];
|
sessionArgNew?: string[];
|
||||||
sessionArgBeforeBody?: boolean;
|
sessionArgResume?: string[];
|
||||||
|
sessionArgBeforeBody?: boolean;
|
||||||
|
sendSystemOnce?: boolean;
|
||||||
|
sessionIntro?: string;
|
||||||
|
typingIntervalSeconds?: number;
|
||||||
|
heartbeatMinutes?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoggingConfig = {
|
export type LoggingConfig = {
|
||||||
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||||
file?: string;
|
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 = {
|
export type WarelayConfig = {
|
||||||
logging?: LoggingConfig;
|
logging?: LoggingConfig;
|
||||||
inbound?: {
|
inbound?: {
|
||||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||||
reply?: {
|
transcribeAudio?: {
|
||||||
mode: ReplyMode;
|
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||||
text?: string; // for mode=text, can contain {{Body}}
|
command: string[];
|
||||||
command?: string[]; // for mode=command, argv with templates
|
timeoutSeconds?: number;
|
||||||
cwd?: string; // working directory for command execution
|
};
|
||||||
template?: string; // prepend template string when building command/prompt
|
reply?: {
|
||||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
mode: ReplyMode;
|
||||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
text?: string; // for mode=text, can contain {{Body}}
|
||||||
mediaUrl?: string; // optional media attachment (path or URL)
|
command?: string[]; // for mode=command, argv with templates
|
||||||
session?: SessionConfig;
|
cwd?: string; // working directory for command execution
|
||||||
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
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");
|
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
||||||
|
|
||||||
const ReplySchema = z
|
const ReplySchema = z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
command: z.array(z.string()).optional(),
|
command: z.array(z.string()).optional(),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
template: z.string().optional(),
|
template: z.string().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
bodyPrefix: z.string().optional(),
|
bodyPrefix: z.string().optional(),
|
||||||
mediaUrl: z.string().optional(),
|
mediaUrl: z.string().optional(),
|
||||||
session: z
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
.object({
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
scope: z
|
session: z
|
||||||
.union([z.literal("per-sender"), z.literal("global")])
|
.object({
|
||||||
.optional(),
|
scope: z
|
||||||
resetTriggers: z.array(z.string()).optional(),
|
.union([z.literal("per-sender"), z.literal("global")])
|
||||||
idleMinutes: z.number().int().positive().optional(),
|
.optional(),
|
||||||
store: z.string().optional(),
|
resetTriggers: z.array(z.string()).optional(),
|
||||||
sessionArgNew: z.array(z.string()).optional(),
|
idleMinutes: z.number().int().positive().optional(),
|
||||||
sessionArgResume: z.array(z.string()).optional(),
|
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||||
sessionArgBeforeBody: z.boolean().optional(),
|
store: z.string().optional(),
|
||||||
})
|
sessionArgNew: z.array(z.string()).optional(),
|
||||||
.optional(),
|
sessionArgResume: z.array(z.string()).optional(),
|
||||||
claudeOutputFormat: z
|
sessionArgBeforeBody: z.boolean().optional(),
|
||||||
.union([
|
sendSystemOnce: z.boolean().optional(),
|
||||||
z.literal("text"),
|
sessionIntro: z.string().optional(),
|
||||||
z.literal("json"),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
z.literal("stream-json"),
|
})
|
||||||
z.undefined(),
|
.optional(),
|
||||||
])
|
heartbeatMinutes: z.number().int().nonnegative().optional(),
|
||||||
.optional(),
|
claudeOutputFormat: z
|
||||||
})
|
.union([
|
||||||
.refine(
|
z.literal("text"),
|
||||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
z.literal("json"),
|
||||||
{
|
z.literal("stream-json"),
|
||||||
message:
|
z.undefined(),
|
||||||
"reply.text is required for mode=text; reply.command is required for mode=command",
|
])
|
||||||
},
|
.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({
|
const WarelaySchema = z.object({
|
||||||
logging: z
|
logging: z
|
||||||
.object({
|
.object({
|
||||||
level: z
|
level: z
|
||||||
.union([
|
.union([
|
||||||
z.literal("silent"),
|
z.literal("silent"),
|
||||||
z.literal("fatal"),
|
z.literal("fatal"),
|
||||||
z.literal("error"),
|
z.literal("error"),
|
||||||
z.literal("warn"),
|
z.literal("warn"),
|
||||||
z.literal("info"),
|
z.literal("info"),
|
||||||
z.literal("debug"),
|
z.literal("debug"),
|
||||||
z.literal("trace"),
|
z.literal("trace"),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
file: z.string().optional(),
|
file: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
inbound: z
|
inbound: z
|
||||||
.object({
|
.object({
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
reply: ReplySchema.optional(),
|
transcribeAudio: z
|
||||||
})
|
.object({
|
||||||
.optional(),
|
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 {
|
export function loadConfig(): WarelayConfig {
|
||||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||||
const parsed = JSON5.parse(raw);
|
const parsed = JSON5.parse(raw);
|
||||||
if (typeof parsed !== "object" || parsed === null) return {};
|
if (typeof parsed !== "object" || parsed === null) return {};
|
||||||
const validated = WarelaySchema.safeParse(parsed);
|
const validated = WarelaySchema.safeParse(parsed);
|
||||||
if (!validated.success) {
|
if (!validated.success) {
|
||||||
console.error("Invalid warelay config:");
|
console.error("Invalid warelay config:");
|
||||||
for (const iss of validated.error.issues) {
|
for (const iss of validated.error.issues) {
|
||||||
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return validated.data as WarelayConfig;
|
return validated.data as WarelayConfig;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-11
@@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { deriveSessionKey } from "./sessions.js";
|
import { deriveSessionKey } from "./sessions.js";
|
||||||
|
|
||||||
describe("sessions", () => {
|
describe("sessions", () => {
|
||||||
it("returns normalized per-sender key", () => {
|
it("returns normalized per-sender key", () => {
|
||||||
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
||||||
"+1555",
|
"+1555",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to unknown when sender missing", () => {
|
it("falls back to unknown when sender missing", () => {
|
||||||
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("global scope returns global", () => {
|
it("global scope returns global", () => {
|
||||||
expect(deriveSessionKey("global", { From: "+1" })).toBe("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 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 SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||||
export const DEFAULT_IDLE_MINUTES = 60;
|
export const DEFAULT_IDLE_MINUTES = 60;
|
||||||
|
|
||||||
export function resolveStorePath(store?: string) {
|
export function resolveStorePath(store?: string) {
|
||||||
if (!store) return SESSION_STORE_DEFAULT;
|
if (!store) return SESSION_STORE_DEFAULT;
|
||||||
if (store.startsWith("~"))
|
if (store.startsWith("~"))
|
||||||
return path.resolve(store.replace("~", os.homedir()));
|
return path.resolve(store.replace("~", os.homedir()));
|
||||||
return path.resolve(store);
|
return path.resolve(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSessionStore(
|
export function loadSessionStore(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
): Record<string, SessionEntry> {
|
): Record<string, SessionEntry> {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(storePath, "utf-8");
|
const raw = fs.readFileSync(storePath, "utf-8");
|
||||||
const parsed = JSON5.parse(raw);
|
const parsed = JSON5.parse(raw);
|
||||||
if (parsed && typeof parsed === "object") {
|
if (parsed && typeof parsed === "object") {
|
||||||
return parsed as Record<string, SessionEntry>;
|
return parsed as Record<string, SessionEntry>;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing/invalid store; we'll recreate it
|
// ignore missing/invalid store; we'll recreate it
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSessionStore(
|
export async function saveSessionStore(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
store: Record<string, SessionEntry>,
|
store: Record<string, SessionEntry>,
|
||||||
) {
|
) {
|
||||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(store, null, 2),
|
JSON.stringify(store, null, 2),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide which session bucket to use (per-sender vs global).
|
// Decide which session bucket to use (per-sender vs global).
|
||||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||||
if (scope === "global") return "global";
|
if (scope === "global") return "global";
|
||||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||||
return from || "unknown";
|
return from || "unknown";
|
||||||
}
|
}
|
||||||
+80
-80
@@ -4,94 +4,94 @@ import { ensureTwilioEnv, readEnv } from "./env.js";
|
|||||||
import type { RuntimeEnv } from "./runtime.js";
|
import type { RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
const baseEnv = {
|
const baseEnv = {
|
||||||
TWILIO_ACCOUNT_SID: "AC123",
|
TWILIO_ACCOUNT_SID: "AC123",
|
||||||
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("env helpers", () => {
|
describe("env helpers", () => {
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
process.env = {};
|
process.env = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
function setEnv(vars: Record<string, string | undefined>) {
|
function setEnv(vars: Record<string, string | undefined>) {
|
||||||
process.env = {};
|
process.env = {};
|
||||||
for (const [k, v] of Object.entries(vars)) {
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
if (v === undefined) delete process.env[k];
|
if (v === undefined) delete process.env[k];
|
||||||
else process.env[k] = v;
|
else process.env[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it("reads env with auth token", () => {
|
it("reads env with auth token", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: "token",
|
TWILIO_AUTH_TOKEN: "token",
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
const cfg = readEnv(runtime);
|
const cfg = readEnv(runtime);
|
||||||
expect(cfg.accountSid).toBe("AC123");
|
expect(cfg.accountSid).toBe("AC123");
|
||||||
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
||||||
if ("authToken" in cfg.auth) {
|
if ("authToken" in cfg.auth) {
|
||||||
expect(cfg.auth.authToken).toBe("token");
|
expect(cfg.auth.authToken).toBe("token");
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Expected auth token");
|
throw new Error("Expected auth token");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads env with API key/secret", () => {
|
it("reads env with API key/secret", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: "key",
|
TWILIO_API_KEY: "key",
|
||||||
TWILIO_API_SECRET: "secret",
|
TWILIO_API_SECRET: "secret",
|
||||||
});
|
});
|
||||||
const cfg = readEnv(runtime);
|
const cfg = readEnv(runtime);
|
||||||
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
||||||
expect(cfg.auth.apiKey).toBe("key");
|
expect(cfg.auth.apiKey).toBe("key");
|
||||||
expect(cfg.auth.apiSecret).toBe("secret");
|
expect(cfg.auth.apiSecret).toBe("secret");
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Expected API key/secret");
|
throw new Error("Expected API key/secret");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast on invalid env", () => {
|
it("fails fast on invalid env", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
TWILIO_ACCOUNT_SID: "",
|
TWILIO_ACCOUNT_SID: "",
|
||||||
TWILIO_WHATSAPP_FROM: "",
|
TWILIO_WHATSAPP_FROM: "",
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => readEnv(runtime)).toThrow("exit");
|
expect(() => readEnv(runtime)).toThrow("exit");
|
||||||
expect(runtime.error).toHaveBeenCalled();
|
expect(runtime.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTwilioEnv passes when token present", () => {
|
it("ensureTwilioEnv passes when token present", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: "token",
|
TWILIO_AUTH_TOKEN: "token",
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTwilioEnv fails when missing auth", () => {
|
it("ensureTwilioEnv fails when missing auth", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+86
-86
@@ -4,103 +4,103 @@ import { danger } from "./globals.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
export type AuthMode =
|
export type AuthMode =
|
||||||
| { accountSid: string; authToken: string }
|
| { accountSid: string; authToken: string }
|
||||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
| { accountSid: string; apiKey: string; apiSecret: string };
|
||||||
|
|
||||||
export type EnvConfig = {
|
export type EnvConfig = {
|
||||||
accountSid: string;
|
accountSid: string;
|
||||||
whatsappFrom: string;
|
whatsappFrom: string;
|
||||||
whatsappSenderSid?: string;
|
whatsappSenderSid?: string;
|
||||||
auth: AuthMode;
|
auth: AuthMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EnvSchema = z
|
const EnvSchema = z
|
||||||
.object({
|
.object({
|
||||||
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
||||||
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
||||||
TWILIO_SENDER_SID: z.string().optional(),
|
TWILIO_SENDER_SID: z.string().optional(),
|
||||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||||
TWILIO_API_KEY: z.string().optional(),
|
TWILIO_API_KEY: z.string().optional(),
|
||||||
TWILIO_API_SECRET: z.string().optional(),
|
TWILIO_API_SECRET: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((val, ctx) => {
|
.superRefine((val, ctx) => {
|
||||||
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!val.TWILIO_AUTH_TOKEN &&
|
!val.TWILIO_AUTH_TOKEN &&
|
||||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||||
) {
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message:
|
message:
|
||||||
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
||||||
// Load and validate Twilio auth + sender configuration from env.
|
// Load and validate Twilio auth + sender configuration from env.
|
||||||
const parsed = EnvSchema.safeParse(process.env);
|
const parsed = EnvSchema.safeParse(process.env);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
runtime.error("Invalid environment configuration:");
|
runtime.error("Invalid environment configuration:");
|
||||||
parsed.error.issues.forEach((iss) => {
|
parsed.error.issues.forEach((iss) => {
|
||||||
runtime.error(`- ${iss.message}`);
|
runtime.error(`- ${iss.message}`);
|
||||||
});
|
});
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
TWILIO_ACCOUNT_SID: accountSid,
|
TWILIO_ACCOUNT_SID: accountSid,
|
||||||
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
||||||
TWILIO_SENDER_SID: whatsappSenderSid,
|
TWILIO_SENDER_SID: whatsappSenderSid,
|
||||||
TWILIO_AUTH_TOKEN: authToken,
|
TWILIO_AUTH_TOKEN: authToken,
|
||||||
TWILIO_API_KEY: apiKey,
|
TWILIO_API_KEY: apiKey,
|
||||||
TWILIO_API_SECRET: apiSecret,
|
TWILIO_API_SECRET: apiSecret,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
let auth: AuthMode;
|
let auth: AuthMode;
|
||||||
if (apiKey && apiSecret) {
|
if (apiKey && apiSecret) {
|
||||||
auth = { accountSid, apiKey, apiSecret };
|
auth = { accountSid, apiKey, apiSecret };
|
||||||
} else if (authToken) {
|
} else if (authToken) {
|
||||||
auth = { accountSid, authToken };
|
auth = { accountSid, authToken };
|
||||||
} else {
|
} else {
|
||||||
runtime.error("Missing Twilio auth configuration");
|
runtime.error("Missing Twilio auth configuration");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
throw new Error("unreachable");
|
throw new Error("unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountSid,
|
accountSid,
|
||||||
whatsappFrom,
|
whatsappFrom,
|
||||||
whatsappSenderSid,
|
whatsappSenderSid,
|
||||||
auth,
|
auth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||||
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
||||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||||
const missing = required.filter((k) => !process.env[k]);
|
const missing = required.filter((k) => !process.env[k]);
|
||||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||||
const hasKey = Boolean(
|
const hasKey = Boolean(
|
||||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||||
);
|
);
|
||||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(
|
danger(
|
||||||
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
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";
|
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";
|
||||||
|
|
||||||
describe("globals", () => {
|
describe("globals", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
setYes(false);
|
setYes(false);
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles verbose flag and logs when enabled", () => {
|
it("toggles verbose flag and logs when enabled", () => {
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
logVerbose("hidden");
|
logVerbose("hidden");
|
||||||
expect(logSpy).not.toHaveBeenCalled();
|
expect(logSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
setVerbose(true);
|
setVerbose(true);
|
||||||
logVerbose("shown");
|
logVerbose("shown");
|
||||||
expect(isVerbose()).toBe(true);
|
expect(isVerbose()).toBe(true);
|
||||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores yes flag", () => {
|
it("stores yes flag", () => {
|
||||||
setYes(true);
|
setYes(true);
|
||||||
expect(isYes()).toBe(true);
|
expect(isYes()).toBe(true);
|
||||||
setYes(false);
|
setYes(false);
|
||||||
expect(isYes()).toBe(false);
|
expect(isYes()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+5
-5
@@ -4,23 +4,23 @@ let globalVerbose = false;
|
|||||||
let globalYes = false;
|
let globalYes = false;
|
||||||
|
|
||||||
export function setVerbose(v: boolean) {
|
export function setVerbose(v: boolean) {
|
||||||
globalVerbose = v;
|
globalVerbose = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVerbose() {
|
export function isVerbose() {
|
||||||
return globalVerbose;
|
return globalVerbose;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logVerbose(message: string) {
|
export function logVerbose(message: string) {
|
||||||
if (globalVerbose) console.log(chalk.gray(message));
|
if (globalVerbose) console.log(chalk.gray(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setYes(v: boolean) {
|
export function setYes(v: boolean) {
|
||||||
globalYes = v;
|
globalYes = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYes() {
|
export function isYes() {
|
||||||
return globalYes;
|
return globalYes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const success = chalk.green;
|
export const success = chalk.green;
|
||||||
|
|||||||
+112
-112
@@ -6,134 +6,134 @@ import * as providerWeb from "./provider-web.js";
|
|||||||
import { defaultRuntime } from "./runtime.js";
|
import { defaultRuntime } from "./runtime.js";
|
||||||
|
|
||||||
vi.mock("twilio", () => {
|
vi.mock("twilio", () => {
|
||||||
const { factory } = createMockTwilio();
|
const { factory } = createMockTwilio();
|
||||||
return { default: factory };
|
return { default: factory };
|
||||||
});
|
});
|
||||||
|
|
||||||
import * as index from "./index.js";
|
import * as index from "./index.js";
|
||||||
import * as provider from "./provider-web.js";
|
import * as provider from "./provider-web.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
index.program.exitOverride();
|
index.program.exitOverride();
|
||||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CLI commands", () => {
|
describe("CLI commands", () => {
|
||||||
it("exposes login command", () => {
|
it("exposes login command", () => {
|
||||||
const names = index.program.commands.map((c) => c.name());
|
const names = index.program.commands.map((c) => c.name());
|
||||||
expect(names).toContain("login");
|
expect(names).toContain("login");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command routes to web provider", async () => {
|
it("send command routes to web provider", async () => {
|
||||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
[
|
[
|
||||||
"send",
|
"send",
|
||||||
"--to",
|
"--to",
|
||||||
"+1555",
|
"+1555",
|
||||||
"--message",
|
"--message",
|
||||||
"hi",
|
"hi",
|
||||||
"--provider",
|
"--provider",
|
||||||
"web",
|
"web",
|
||||||
"--wait",
|
"--wait",
|
||||||
"0",
|
"0",
|
||||||
],
|
],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(sendWeb).toHaveBeenCalled();
|
expect(sendWeb).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command uses twilio path when provider=twilio", async () => {
|
it("send command uses twilio path when provider=twilio", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||||
expect(wait).not.toHaveBeenCalled();
|
expect(wait).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command supports dry-run and skips sending", async () => {
|
it("send command supports dry-run and skips sending", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||||
expect(wait).not.toHaveBeenCalled();
|
expect(wait).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command outputs JSON when requested", async () => {
|
it("send command outputs JSON when requested", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
||||||
const logSpy = vi.spyOn(defaultRuntime, "log");
|
const logSpy = vi.spyOn(defaultRuntime, "log");
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(logSpy).toHaveBeenCalledWith(
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"sid": "SMJSON"'),
|
expect.stringContaining('"sid": "SMJSON"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("login command calls web login", async () => {
|
it("login command calls web login", async () => {
|
||||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||||
await index.program.parseAsync(["login"], { from: "user" });
|
await index.program.parseAsync(["login"], { from: "user" });
|
||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("status command prints JSON", async () => {
|
it("status command prints JSON", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.list
|
twilio._client.messages.list
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
sid: "1",
|
sid: "1",
|
||||||
status: "delivered",
|
status: "delivered",
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||||
from: "a",
|
from: "a",
|
||||||
to: "b",
|
to: "b",
|
||||||
body: "hi",
|
body: "hi",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
sid: "2",
|
sid: "2",
|
||||||
status: "sent",
|
status: "sent",
|
||||||
direction: "outbound-api",
|
direction: "outbound-api",
|
||||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||||
from: "b",
|
from: "b",
|
||||||
to: "a",
|
to: "a",
|
||||||
body: "yo",
|
body: "yo",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
...defaultRuntime,
|
...defaultRuntime,
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await statusCommand(
|
await statusCommand(
|
||||||
{ limit: "1", lookback: "10", json: true },
|
{ limit: "1", lookback: "10", json: true },
|
||||||
createDefaultDeps(),
|
createDefaultDeps(),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalled();
|
expect(runtime.log).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+1168
-850
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";
|
import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js";
|
||||||
|
|
||||||
describe("normalizeE164", () => {
|
describe("normalizeE164", () => {
|
||||||
it("strips whatsapp prefix and whitespace", () => {
|
it("strips whatsapp prefix and whitespace", () => {
|
||||||
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds plus when missing", () => {
|
it("adds plus when missing", () => {
|
||||||
expect(normalizeE164("1555123")).toBe("+1555123");
|
expect(normalizeE164("1555123")).toBe("+1555123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toWhatsappJid", () => {
|
describe("toWhatsappJid", () => {
|
||||||
it("converts E164 to jid", () => {
|
it("converts E164 to jid", () => {
|
||||||
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assertProvider", () => {
|
describe("assertProvider", () => {
|
||||||
it("accepts valid providers", () => {
|
it("accepts valid providers", () => {
|
||||||
expect(() => assertProvider("twilio")).not.toThrow();
|
expect(() => assertProvider("twilio")).not.toThrow();
|
||||||
expect(() => assertProvider("web")).not.toThrow();
|
expect(() => assertProvider("web")).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on invalid provider", () => {
|
it("throws on invalid provider", () => {
|
||||||
expect(() => assertProvider("invalid" as string)).toThrow();
|
expect(() => assertProvider("invalid" as string)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+87
-69
@@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {
|
import {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
getReplyFromConfig,
|
getReplyFromConfig,
|
||||||
} from "./auto-reply/reply.js";
|
} from "./auto-reply/reply.js";
|
||||||
import { applyTemplate } from "./auto-reply/templating.js";
|
import { applyTemplate } from "./auto-reply/templating.js";
|
||||||
import { createDefaultDeps, monitorTwilio } from "./cli/deps.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 { waitForever } from "./cli/wait.js";
|
||||||
import { loadConfig } from "./config/config.js";
|
import { loadConfig } from "./config/config.js";
|
||||||
import {
|
import {
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "./config/sessions.js";
|
} from "./config/sessions.js";
|
||||||
import { readEnv } from "./env.js";
|
import { readEnv } from "./env.js";
|
||||||
import { ensureBinary } from "./infra/binaries.js";
|
import { ensureBinary } from "./infra/binaries.js";
|
||||||
import {
|
import {
|
||||||
describePortOwner,
|
describePortOwner,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
} from "./infra/ports.js";
|
} from "./infra/ports.js";
|
||||||
import {
|
import {
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
} from "./infra/tailscale.js";
|
} from "./infra/tailscale.js";
|
||||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||||
import { monitorWebProvider } from "./provider-web.js";
|
import { monitorWebProvider } from "./provider-web.js";
|
||||||
import { createClient } from "./twilio/client.js";
|
import { createClient } from "./twilio/client.js";
|
||||||
import {
|
import {
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
sortByDateDesc,
|
sortByDateDesc,
|
||||||
uniqueBySid,
|
uniqueBySid,
|
||||||
} from "./twilio/messages.js";
|
} from "./twilio/messages.js";
|
||||||
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
||||||
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
||||||
import { sendTypingIndicator } from "./twilio/typing.js";
|
import { sendTypingIndicator } from "./twilio/typing.js";
|
||||||
import {
|
import {
|
||||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||||
updateWebhook as updateWebhookImpl,
|
updateWebhook as updateWebhookImpl,
|
||||||
} from "./twilio/update-webhook.js";
|
} from "./twilio/update-webhook.js";
|
||||||
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
||||||
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
||||||
@@ -66,56 +66,74 @@ const setMessagingServiceWebhook = setMessagingServiceWebhookImpl;
|
|||||||
const updateWebhook = updateWebhookImpl;
|
const updateWebhook = updateWebhookImpl;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
assertProvider,
|
assertProvider,
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
createClient,
|
createClient,
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
describePortOwner,
|
describePortOwner,
|
||||||
ensureBinary,
|
ensureBinary,
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
findIncomingNumberSidImpl as findIncomingNumberSid,
|
findIncomingNumberSidImpl as findIncomingNumberSid,
|
||||||
findMessagingServiceSidImpl as findMessagingServiceSid,
|
findMessagingServiceSidImpl as findMessagingServiceSid,
|
||||||
findWhatsappSenderSid,
|
findWhatsappSenderSid,
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
formatTwilioError,
|
formatTwilioError,
|
||||||
getReplyFromConfig,
|
getReplyFromConfig,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
logTwilioSendError,
|
logTwilioSendError,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
promptYesNo,
|
promptYesNo,
|
||||||
createDefaultDeps,
|
createDefaultDeps,
|
||||||
readEnv,
|
readEnv,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
runCommandWithTimeout,
|
runCommandWithTimeout,
|
||||||
runExec,
|
runExec,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendTypingIndicator,
|
sendTypingIndicator,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
sortByDateDesc,
|
sortByDateDesc,
|
||||||
startWebhook,
|
startWebhook,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
uniqueBySid,
|
uniqueBySid,
|
||||||
waitForFinalStatus,
|
waitForFinalStatus,
|
||||||
waitForever,
|
waitForever,
|
||||||
toWhatsappJid,
|
toWhatsappJid,
|
||||||
program,
|
program,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMain =
|
const isMain =
|
||||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||||
|
|
||||||
if (isMain) {
|
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";
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
|
||||||
describe("ensureBinary", () => {
|
describe("ensureBinary", () => {
|
||||||
it("passes through when binary exists", async () => {
|
it("passes through when binary exists", async () => {
|
||||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||||
stdout: "",
|
stdout: "",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
});
|
});
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(),
|
exit: vi.fn(),
|
||||||
};
|
};
|
||||||
await ensureBinary("node", exec, runtime);
|
await ensureBinary("node", exec, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs and exits when missing", async () => {
|
it("logs and exits when missing", async () => {
|
||||||
const exec: typeof runExec = vi
|
const exec: typeof runExec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValue(new Error("missing"));
|
.mockRejectedValue(new Error("missing"));
|
||||||
const error = vi.fn();
|
const error = vi.fn();
|
||||||
const exit = vi.fn(() => {
|
const exit = vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||||
).rejects.toThrow("exit");
|
).rejects.toThrow("exit");
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
"Missing required binary: ghost. Please install it.",
|
"Missing required binary: ghost. Please install it.",
|
||||||
);
|
);
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
expect(exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,13 +2,13 @@ import { runExec } from "../process/exec.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
export async function ensureBinary(
|
export async function ensureBinary(
|
||||||
name: string,
|
name: string,
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Abort early if a required CLI tool is missing.
|
// Abort early if a required CLI tool is missing.
|
||||||
await exec("which", [name]).catch(() => {
|
await exec("which", [name]).catch(() => {
|
||||||
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+27
-27
@@ -2,35 +2,35 @@ import net from "node:net";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
|
|
||||||
describe("ports helpers", () => {
|
describe("ports helpers", () => {
|
||||||
it("ensurePortAvailable rejects when port busy", async () => {
|
it("ensurePortAvailable rejects when port busy", async () => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
await new Promise((resolve) => server.listen(0, resolve));
|
await new Promise((resolve) => server.listen(0, resolve));
|
||||||
const port = (server.address() as net.AddressInfo).port;
|
const port = (server.address() as net.AddressInfo).port;
|
||||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
);
|
);
|
||||||
server.close();
|
server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: vi.fn() as unknown as (code: number) => never,
|
exit: vi.fn() as unknown as (code: number) => never,
|
||||||
};
|
};
|
||||||
await handlePortError(
|
await handlePortError(
|
||||||
{ code: "EADDRINUSE" },
|
{ code: "EADDRINUSE" },
|
||||||
1234,
|
1234,
|
||||||
"context",
|
"context",
|
||||||
runtime,
|
runtime,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
expect(runtime.error).toHaveBeenCalled();
|
expect(runtime.error).toHaveBeenCalled();
|
||||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+82
-82
@@ -5,103 +5,103 @@ import { runExec } from "../process/exec.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
class PortInUseError extends Error {
|
class PortInUseError extends Error {
|
||||||
port: number;
|
port: number;
|
||||||
details?: string;
|
details?: string;
|
||||||
|
|
||||||
constructor(port: number, details?: string) {
|
constructor(port: number, details?: string) {
|
||||||
super(`Port ${port} is already in use.`);
|
super(`Port ${port} is already in use.`);
|
||||||
this.name = "PortInUseError";
|
this.name = "PortInUseError";
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
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(
|
export async function describePortOwner(
|
||||||
port: number,
|
port: number,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
// Best-effort process info for a listening port (macOS/Linux).
|
// Best-effort process info for a listening port (macOS/Linux).
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runExec("lsof", [
|
const { stdout } = await runExec("lsof", [
|
||||||
"-i",
|
"-i",
|
||||||
`tcp:${port}`,
|
`tcp:${port}`,
|
||||||
"-sTCP:LISTEN",
|
"-sTCP:LISTEN",
|
||||||
"-nP",
|
"-nP",
|
||||||
]);
|
]);
|
||||||
const trimmed = stdout.trim();
|
const trimmed = stdout.trim();
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) return trimmed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`lsof unavailable: ${String(err)}`);
|
logVerbose(`lsof unavailable: ${String(err)}`);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensurePortAvailable(port: number): Promise<void> {
|
export async function ensurePortAvailable(port: number): Promise<void> {
|
||||||
// Detect EADDRINUSE early with a friendly message.
|
// Detect EADDRINUSE early with a friendly message.
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const tester = net
|
const tester = net
|
||||||
.createServer()
|
.createServer()
|
||||||
.once("error", (err) => reject(err))
|
.once("error", (err) => reject(err))
|
||||||
.once("listening", () => {
|
.once("listening", () => {
|
||||||
tester.close(() => resolve());
|
tester.close(() => resolve());
|
||||||
})
|
})
|
||||||
.listen(port);
|
.listen(port);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||||
const details = await describePortOwner(port);
|
const details = await describePortOwner(port);
|
||||||
throw new PortInUseError(port, details);
|
throw new PortInUseError(port, details);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePortError(
|
export async function handlePortError(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
port: number,
|
port: number,
|
||||||
context: string,
|
context: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
// Uniform messaging for EADDRINUSE with optional owner details.
|
// Uniform messaging for EADDRINUSE with optional owner details.
|
||||||
if (
|
if (
|
||||||
err instanceof PortInUseError ||
|
err instanceof PortInUseError ||
|
||||||
(isErrno(err) && err.code === "EADDRINUSE")
|
(isErrno(err) && err.code === "EADDRINUSE")
|
||||||
) {
|
) {
|
||||||
const details =
|
const details =
|
||||||
err instanceof PortInUseError
|
err instanceof PortInUseError
|
||||||
? err.details
|
? err.details
|
||||||
: await describePortOwner(port);
|
: await describePortOwner(port);
|
||||||
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
||||||
if (details) {
|
if (details) {
|
||||||
runtime.error(info("Port listener details:"));
|
runtime.error(info("Port listener details:"));
|
||||||
runtime.error(details);
|
runtime.error(details);
|
||||||
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
warn(
|
warn(
|
||||||
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
runtime.error(danger(`${context} failed: ${String(err)}`));
|
runtime.error(danger(`${context} failed: ${String(err)}`));
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
const stdout = (err as { stdout?: string })?.stdout;
|
const stdout = (err as { stdout?: string })?.stdout;
|
||||||
const stderr = (err as { stderr?: string })?.stderr;
|
const stderr = (err as { stderr?: string })?.stderr;
|
||||||
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
||||||
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
||||||
}
|
}
|
||||||
return runtime.exit(1);
|
return runtime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { PortInUseError };
|
export { PortInUseError };
|
||||||
+20
-20
@@ -3,26 +3,26 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { retryAsync } from "./retry.js";
|
import { retryAsync } from "./retry.js";
|
||||||
|
|
||||||
describe("retryAsync", () => {
|
describe("retryAsync", () => {
|
||||||
it("returns on first success", async () => {
|
it("returns on first success", async () => {
|
||||||
const fn = vi.fn().mockResolvedValue("ok");
|
const fn = vi.fn().mockResolvedValue("ok");
|
||||||
const result = await retryAsync(fn, 3, 10);
|
const result = await retryAsync(fn, 3, 10);
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
expect(fn).toHaveBeenCalledTimes(1);
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries then succeeds", async () => {
|
it("retries then succeeds", async () => {
|
||||||
const fn = vi
|
const fn = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("fail1"))
|
.mockRejectedValueOnce(new Error("fail1"))
|
||||||
.mockResolvedValueOnce("ok");
|
.mockResolvedValueOnce("ok");
|
||||||
const result = await retryAsync(fn, 3, 1);
|
const result = await retryAsync(fn, 3, 1);
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("propagates after exhausting retries", async () => {
|
it("propagates after exhausting retries", async () => {
|
||||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||||
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+15
-15
@@ -1,18 +1,18 @@
|
|||||||
export async function retryAsync<T>(
|
export async function retryAsync<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
attempts = 3,
|
attempts = 3,
|
||||||
initialDelayMs = 300,
|
initialDelayMs = 300,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
for (let i = 0; i < attempts; i += 1) {
|
for (let i = 0; i < attempts; i += 1) {
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
if (i === attempts - 1) break;
|
if (i === attempts - 1) break;
|
||||||
const delay = initialDelayMs * 2 ** i;
|
const delay = initialDelayMs * 2 ** i;
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastErr;
|
throw lastErr;
|
||||||
}
|
}
|
||||||
+51
-51
@@ -1,61 +1,61 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
} from "./tailscale.js";
|
} from "./tailscale.js";
|
||||||
|
|
||||||
describe("tailscale helpers", () => {
|
describe("tailscale helpers", () => {
|
||||||
it("parses DNS name from tailscale status", async () => {
|
it("parses DNS name from tailscale status", async () => {
|
||||||
const exec = vi.fn().mockResolvedValue({
|
const exec = vi.fn().mockResolvedValue({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const host = await getTailnetHostname(exec);
|
const host = await getTailnetHostname(exec);
|
||||||
expect(host).toBe("host.tailnet.ts.net");
|
expect(host).toBe("host.tailnet.ts.net");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to IP when DNS missing", async () => {
|
it("falls back to IP when DNS missing", async () => {
|
||||||
const exec = vi.fn().mockResolvedValue({
|
const exec = vi.fn().mockResolvedValue({
|
||||||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
||||||
});
|
});
|
||||||
const host = await getTailnetHostname(exec);
|
const host = await getTailnetHostname(exec);
|
||||||
expect(host).toBe("100.2.2.2");
|
expect(host).toBe("100.2.2.2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
||||||
const exec = vi
|
const exec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("no go"))
|
.mockRejectedValueOnce(new Error("no go"))
|
||||||
.mockResolvedValue({}); // brew install go
|
.mockResolvedValue({}); // brew install go
|
||||||
const prompt = vi.fn().mockResolvedValue(true);
|
const prompt = vi.fn().mockResolvedValue(true);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await ensureGoInstalled(exec as never, prompt, runtime);
|
await ensureGoInstalled(exec as never, prompt, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
||||||
const exec = vi
|
const exec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("missing"))
|
.mockRejectedValueOnce(new Error("missing"))
|
||||||
.mockResolvedValue({});
|
.mockResolvedValue({});
|
||||||
const prompt = vi.fn().mockResolvedValue(true);
|
const prompt = vi.fn().mockResolvedValue(true);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+140
-140
@@ -6,158 +6,158 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|||||||
import { ensureBinary } from "./binaries.js";
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
|
||||||
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||||
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
||||||
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
||||||
const self =
|
const self =
|
||||||
typeof parsed.Self === "object" && parsed.Self !== null
|
typeof parsed.Self === "object" && parsed.Self !== null
|
||||||
? (parsed.Self as Record<string, unknown>)
|
? (parsed.Self as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
const dns =
|
const dns =
|
||||||
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
||||||
const ips = Array.isArray(self?.TailscaleIPs)
|
const ips = Array.isArray(self?.TailscaleIPs)
|
||||||
? (self.TailscaleIPs as string[])
|
? (self.TailscaleIPs as string[])
|
||||||
: [];
|
: [];
|
||||||
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
||||||
if (ips.length > 0) return ips[0];
|
if (ips.length > 0) return ips[0];
|
||||||
throw new Error("Could not determine Tailscale DNS or IP");
|
throw new Error("Could not determine Tailscale DNS or IP");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureGoInstalled(
|
export async function ensureGoInstalled(
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
||||||
const hasGo = await exec("go", ["version"]).then(
|
const hasGo = await exec("go", ["version"]).then(
|
||||||
() => true,
|
() => true,
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
if (hasGo) return;
|
if (hasGo) return;
|
||||||
const install = await prompt(
|
const install = await prompt(
|
||||||
"Go is not installed. Install via Homebrew (brew install go)?",
|
"Go is not installed. Install via Homebrew (brew install go)?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!install) {
|
if (!install) {
|
||||||
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
logVerbose("Installing Go via Homebrew…");
|
logVerbose("Installing Go via Homebrew…");
|
||||||
await exec("brew", ["install", "go"]);
|
await exec("brew", ["install", "go"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureTailscaledInstalled(
|
export async function ensureTailscaledInstalled(
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
||||||
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
||||||
() => true,
|
() => true,
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
if (hasTailscaled) return;
|
if (hasTailscaled) return;
|
||||||
|
|
||||||
const install = await prompt(
|
const install = await prompt(
|
||||||
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!install) {
|
if (!install) {
|
||||||
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
logVerbose("Installing tailscaled via Homebrew…");
|
logVerbose("Installing tailscaled via Homebrew…");
|
||||||
await exec("brew", ["install", "tailscale"]);
|
await exec("brew", ["install", "tailscale"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureFunnel(
|
export async function ensureFunnel(
|
||||||
port: number,
|
port: number,
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
) {
|
) {
|
||||||
// Ensure Funnel is enabled and publish the webhook port.
|
// Ensure Funnel is enabled and publish the webhook port.
|
||||||
try {
|
try {
|
||||||
const statusOut = (
|
const statusOut = (
|
||||||
await exec("tailscale", ["funnel", "status", "--json"])
|
await exec("tailscale", ["funnel", "status", "--json"])
|
||||||
).stdout.trim();
|
).stdout.trim();
|
||||||
const parsed = statusOut
|
const parsed = statusOut
|
||||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
if (!parsed || Object.keys(parsed).length === 0) {
|
if (!parsed || Object.keys(parsed).length === 0) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const proceed = await prompt(
|
const proceed = await prompt(
|
||||||
"Attempt local setup with user-space tailscaled?",
|
"Attempt local setup with user-space tailscaled?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!proceed) runtime.exit(1);
|
if (!proceed) runtime.exit(1);
|
||||||
await ensureBinary("brew", exec, runtime);
|
await ensureBinary("brew", exec, runtime);
|
||||||
await ensureGoInstalled(exec, prompt, runtime);
|
await ensureGoInstalled(exec, prompt, runtime);
|
||||||
await ensureTailscaledInstalled(exec, prompt, runtime);
|
await ensureTailscaledInstalled(exec, prompt, runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
logVerbose(`Enabling funnel on port ${port}…`);
|
logVerbose(`Enabling funnel on port ${port}…`);
|
||||||
const { stdout } = await exec(
|
const { stdout } = await exec(
|
||||||
"tailscale",
|
"tailscale",
|
||||||
["funnel", "--yes", "--bg", `${port}`],
|
["funnel", "--yes", "--bg", `${port}`],
|
||||||
{
|
{
|
||||||
maxBuffer: 200_000,
|
maxBuffer: 200_000,
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (stdout.trim()) console.log(stdout.trim());
|
if (stdout.trim()) console.log(stdout.trim());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
||||||
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
||||||
if (stdout.includes("Funnel is not enabled")) {
|
if (stdout.includes("Funnel is not enabled")) {
|
||||||
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
||||||
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
||||||
if (linkMatch) {
|
if (linkMatch) {
|
||||||
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
info(
|
info(
|
||||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
stderr.includes("client version") ||
|
stderr.includes("client version") ||
|
||||||
stdout.includes("client version")
|
stdout.includes("client version")
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
warn(
|
warn(
|
||||||
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
||||||
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+55
-55
@@ -11,72 +11,72 @@ import { resetLogger, setLoggerOverride } from "./logging.js";
|
|||||||
import type { RuntimeEnv } from "./runtime.js";
|
import type { RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
describe("logger helpers", () => {
|
describe("logger helpers", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetLogger();
|
resetLogger();
|
||||||
setLoggerOverride(null);
|
setLoggerOverride(null);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats messages through runtime log/error", () => {
|
it("formats messages through runtime log/error", () => {
|
||||||
const log = vi.fn();
|
const log = vi.fn();
|
||||||
const error = vi.fn();
|
const error = vi.fn();
|
||||||
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
||||||
|
|
||||||
logInfo("info", runtime);
|
logInfo("info", runtime);
|
||||||
logWarn("warn", runtime);
|
logWarn("warn", runtime);
|
||||||
logSuccess("ok", runtime);
|
logSuccess("ok", runtime);
|
||||||
logError("bad", runtime);
|
logError("bad", runtime);
|
||||||
|
|
||||||
expect(log).toHaveBeenCalledTimes(3);
|
expect(log).toHaveBeenCalledTimes(3);
|
||||||
expect(error).toHaveBeenCalledTimes(1);
|
expect(error).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only logs debug when verbose is enabled", () => {
|
it("only logs debug when verbose is enabled", () => {
|
||||||
const logVerbose = vi.spyOn(console, "log");
|
const logVerbose = vi.spyOn(console, "log");
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
logDebug("quiet");
|
logDebug("quiet");
|
||||||
expect(logVerbose).not.toHaveBeenCalled();
|
expect(logVerbose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
setVerbose(true);
|
setVerbose(true);
|
||||||
logVerbose.mockClear();
|
logVerbose.mockClear();
|
||||||
logDebug("loud");
|
logDebug("loud");
|
||||||
expect(logVerbose).toHaveBeenCalled();
|
expect(logVerbose).toHaveBeenCalled();
|
||||||
logVerbose.mockRestore();
|
logVerbose.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes to configured log file at configured level", () => {
|
it("writes to configured log file at configured level", () => {
|
||||||
const logPath = pathForTest();
|
const logPath = pathForTest();
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
setLoggerOverride({ level: "debug", file: logPath });
|
setLoggerOverride({ level: "debug", file: logPath });
|
||||||
logInfo("hello");
|
logInfo("hello");
|
||||||
logDebug("debug-only");
|
logDebug("debug-only");
|
||||||
const content = fs.readFileSync(logPath, "utf-8");
|
const content = fs.readFileSync(logPath, "utf-8");
|
||||||
expect(content).toContain("hello");
|
expect(content).toContain("hello");
|
||||||
expect(content).toContain("debug-only");
|
expect(content).toContain("debug-only");
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters messages below configured level", () => {
|
it("filters messages below configured level", () => {
|
||||||
const logPath = pathForTest();
|
const logPath = pathForTest();
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
setLoggerOverride({ level: "warn", file: logPath });
|
setLoggerOverride({ level: "warn", file: logPath });
|
||||||
logInfo("info-only");
|
logInfo("info-only");
|
||||||
logWarn("warn-only");
|
logWarn("warn-only");
|
||||||
const content = fs.readFileSync(logPath, "utf-8");
|
const content = fs.readFileSync(logPath, "utf-8");
|
||||||
expect(content).not.toContain("info-only");
|
expect(content).not.toContain("info-only");
|
||||||
expect(content).toContain("warn-only");
|
expect(content).toContain("warn-only");
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function pathForTest() {
|
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) {
|
function cleanup(file: string) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(file, { force: true });
|
fs.rmSync(file, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+21
-21
@@ -1,42 +1,42 @@
|
|||||||
import {
|
import {
|
||||||
danger,
|
danger,
|
||||||
info,
|
info,
|
||||||
isVerbose,
|
isVerbose,
|
||||||
logVerbose,
|
logVerbose,
|
||||||
success,
|
success,
|
||||||
warn,
|
warn,
|
||||||
} from "./globals.js";
|
} from "./globals.js";
|
||||||
import { getLogger } from "./logging.js";
|
import { getLogger } from "./logging.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||||
runtime.log(info(message));
|
runtime.log(info(message));
|
||||||
getLogger().info(message);
|
getLogger().info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||||
runtime.log(warn(message));
|
runtime.log(warn(message));
|
||||||
getLogger().warn(message);
|
getLogger().warn(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logSuccess(
|
export function logSuccess(
|
||||||
message: string,
|
message: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
runtime.log(success(message));
|
runtime.log(success(message));
|
||||||
getLogger().info(message);
|
getLogger().info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logError(
|
export function logError(
|
||||||
message: string,
|
message: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
runtime.error(danger(message));
|
runtime.error(danger(message));
|
||||||
getLogger().error(message);
|
getLogger().error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logDebug(message: string) {
|
export function logDebug(message: string) {
|
||||||
// Always emit to file logger (level-filtered); console only when verbose.
|
// Always emit to file logger (level-filtered); console only when verbose.
|
||||||
getLogger().debug(message);
|
getLogger().debug(message);
|
||||||
if (isVerbose()) logVerbose(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");
|
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log");
|
||||||
|
|
||||||
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
||||||
"silent",
|
"silent",
|
||||||
"fatal",
|
"fatal",
|
||||||
"error",
|
"error",
|
||||||
"warn",
|
"warn",
|
||||||
"info",
|
"info",
|
||||||
"debug",
|
"debug",
|
||||||
"trace",
|
"trace",
|
||||||
];
|
];
|
||||||
|
|
||||||
export type LoggerSettings = {
|
export type LoggerSettings = {
|
||||||
level?: LevelWithSilent;
|
level?: LevelWithSilent;
|
||||||
file?: string;
|
file?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedSettings = {
|
type ResolvedSettings = {
|
||||||
level: LevelWithSilent;
|
level: LevelWithSilent;
|
||||||
file: string;
|
file: string;
|
||||||
};
|
};
|
||||||
|
export type LoggerResolvedSettings = ResolvedSettings;
|
||||||
|
|
||||||
let cachedLogger: Logger | null = null;
|
let cachedLogger: Logger | null = null;
|
||||||
let cachedSettings: ResolvedSettings | null = null;
|
let cachedSettings: ResolvedSettings | null = null;
|
||||||
let overrideSettings: LoggerSettings | null = null;
|
let overrideSettings: LoggerSettings | null = null;
|
||||||
|
|
||||||
function normalizeLevel(level?: string): LevelWithSilent {
|
function normalizeLevel(level?: string): LevelWithSilent {
|
||||||
if (isVerbose()) return "debug";
|
if (isVerbose()) return "debug";
|
||||||
const candidate = level ?? "info";
|
const candidate = level ?? "info";
|
||||||
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
||||||
? (candidate as LevelWithSilent)
|
? (candidate as LevelWithSilent)
|
||||||
: "info";
|
: "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSettings(): ResolvedSettings {
|
function resolveSettings(): ResolvedSettings {
|
||||||
const cfg: WarelayConfig["logging"] | undefined =
|
const cfg: WarelayConfig["logging"] | undefined =
|
||||||
overrideSettings ?? loadConfig().logging;
|
overrideSettings ?? loadConfig().logging;
|
||||||
const level = normalizeLevel(cfg?.level);
|
const level = normalizeLevel(cfg?.level);
|
||||||
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
||||||
return { level, file };
|
return { level, file };
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
||||||
if (!a) return true;
|
if (!a) return true;
|
||||||
return a.level !== b.level || a.file !== b.file;
|
return a.level !== b.level || a.file !== b.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLogger(settings: ResolvedSettings): Logger {
|
function buildLogger(settings: ResolvedSettings): Logger {
|
||||||
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
||||||
const destination = pino.destination({
|
const destination = pino.destination({
|
||||||
dest: settings.file,
|
dest: settings.file,
|
||||||
mkdir: true,
|
mkdir: true,
|
||||||
sync: true, // deterministic for tests; log volume is modest.
|
sync: true, // deterministic for tests; log volume is modest.
|
||||||
});
|
});
|
||||||
return pino(
|
return pino(
|
||||||
{
|
{
|
||||||
level: settings.level,
|
level: settings.level,
|
||||||
base: undefined,
|
base: undefined,
|
||||||
timestamp: pino.stdTimeFunctions.isoTime,
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
},
|
},
|
||||||
destination,
|
destination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLogger(): Logger {
|
export function getLogger(): Logger {
|
||||||
const settings = resolveSettings();
|
const settings = resolveSettings();
|
||||||
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
||||||
cachedLogger = buildLogger(settings);
|
cachedLogger = buildLogger(settings);
|
||||||
cachedSettings = settings;
|
cachedSettings = settings;
|
||||||
}
|
}
|
||||||
return cachedLogger;
|
return cachedLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChildLogger(
|
export function getChildLogger(
|
||||||
bindings?: Bindings,
|
bindings?: Bindings,
|
||||||
opts?: { level?: LevelWithSilent },
|
opts?: { level?: LevelWithSilent },
|
||||||
): Logger {
|
): Logger {
|
||||||
return getLogger().child(bindings ?? {}, opts);
|
return getLogger().child(bindings ?? {}, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResolvedLoggerSettings(): LoggerResolvedSettings {
|
||||||
|
return resolveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test helpers
|
// Test helpers
|
||||||
export function setLoggerOverride(settings: LoggerSettings | null) {
|
export function setLoggerOverride(settings: LoggerSettings | null) {
|
||||||
overrideSettings = settings;
|
overrideSettings = settings;
|
||||||
cachedLogger = null;
|
cachedLogger = null;
|
||||||
cachedSettings = null;
|
cachedSettings = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetLogger() {
|
export function resetLogger() {
|
||||||
cachedLogger = null;
|
cachedLogger = null;
|
||||||
cachedSettings = null;
|
cachedSettings = null;
|
||||||
overrideSettings = 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("./store.js", () => ({ saveMediaSource }));
|
||||||
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
||||||
vi.mock("../infra/ports.js", async () => {
|
vi.mock("../infra/ports.js", async () => {
|
||||||
const actual =
|
const actual =
|
||||||
await vi.importActual<typeof import("../infra/ports.js")>(
|
await vi.importActual<typeof import("../infra/ports.js")>(
|
||||||
"../infra/ports.js",
|
"../infra/ports.js",
|
||||||
);
|
);
|
||||||
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
||||||
});
|
});
|
||||||
vi.mock("./server.js", () => ({ startMediaServer }));
|
vi.mock("./server.js", () => ({ startMediaServer }));
|
||||||
vi.mock("../logger.js", () => ({ logInfo }));
|
vi.mock("../logger.js", () => ({ logInfo }));
|
||||||
@@ -25,69 +25,69 @@ const { ensureMediaHosted } = await import("./host.js");
|
|||||||
const { PortInUseError } = await import("../infra/ports.js");
|
const { PortInUseError } = await import("../infra/ports.js");
|
||||||
|
|
||||||
describe("ensureMediaHosted", () => {
|
describe("ensureMediaHosted", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws and cleans up when server not allowed to start", async () => {
|
it("throws and cleans up when server not allowed to start", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id1",
|
id: "id1",
|
||||||
path: "/tmp/file1",
|
path: "/tmp/file1",
|
||||||
size: 5,
|
size: 5,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tailnet-host");
|
getTailnetHostname.mockResolvedValue("tailnet-host");
|
||||||
ensurePortAvailable.mockResolvedValue(undefined);
|
ensurePortAvailable.mockResolvedValue(undefined);
|
||||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
||||||
).rejects.toThrow("requires the webhook/Funnel server");
|
).rejects.toThrow("requires the webhook/Funnel server");
|
||||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
||||||
rmSpy.mockRestore();
|
rmSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts media server when allowed", async () => {
|
it("starts media server when allowed", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id2",
|
id: "id2",
|
||||||
path: "/tmp/file2",
|
path: "/tmp/file2",
|
||||||
size: 9,
|
size: 9,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tail.net");
|
getTailnetHostname.mockResolvedValue("tail.net");
|
||||||
ensurePortAvailable.mockResolvedValue(undefined);
|
ensurePortAvailable.mockResolvedValue(undefined);
|
||||||
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
||||||
startMediaServer.mockResolvedValue(fakeServer);
|
startMediaServer.mockResolvedValue(fakeServer);
|
||||||
|
|
||||||
const result = await ensureMediaHosted("/tmp/file2", {
|
const result = await ensureMediaHosted("/tmp/file2", {
|
||||||
startServer: true,
|
startServer: true,
|
||||||
port: 1234,
|
port: 1234,
|
||||||
});
|
});
|
||||||
expect(startMediaServer).toHaveBeenCalledWith(
|
expect(startMediaServer).toHaveBeenCalledWith(
|
||||||
1234,
|
1234,
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
expect(logInfo).toHaveBeenCalled();
|
expect(logInfo).toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
url: "https://tail.net/media/id2",
|
url: "https://tail.net/media/id2",
|
||||||
id: "id2",
|
id: "id2",
|
||||||
size: 9,
|
size: 9,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips server start when port already in use", async () => {
|
it("skips server start when port already in use", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id3",
|
id: "id3",
|
||||||
path: "/tmp/file3",
|
path: "/tmp/file3",
|
||||||
size: 7,
|
size: 7,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tail.net");
|
getTailnetHostname.mockResolvedValue("tail.net");
|
||||||
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
||||||
|
|
||||||
const result = await ensureMediaHosted("/tmp/file3", {
|
const result = await ensureMediaHosted("/tmp/file3", {
|
||||||
startServer: false,
|
startServer: false,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
});
|
});
|
||||||
expect(startMediaServer).not.toHaveBeenCalled();
|
expect(startMediaServer).not.toHaveBeenCalled();
|
||||||
expect(result.url).toBe("https://tail.net/media/id3");
|
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;
|
let mediaServer: import("http").Server | null = null;
|
||||||
|
|
||||||
export type HostedMedia = {
|
export type HostedMedia = {
|
||||||
url: string;
|
url: string;
|
||||||
id: string;
|
id: string;
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function ensureMediaHosted(
|
export async function ensureMediaHosted(
|
||||||
source: string,
|
source: string,
|
||||||
opts: {
|
opts: {
|
||||||
port?: number;
|
port?: number;
|
||||||
startServer?: boolean;
|
startServer?: boolean;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<HostedMedia> {
|
): Promise<HostedMedia> {
|
||||||
const port = opts.port ?? DEFAULT_PORT;
|
const port = opts.port ?? DEFAULT_PORT;
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
|
|
||||||
const saved = await saveMediaSource(source);
|
const saved = await saveMediaSource(source);
|
||||||
const hostname = await getTailnetHostname();
|
const hostname = await getTailnetHostname();
|
||||||
|
|
||||||
// Decide whether we must start a media server.
|
// Decide whether we must start a media server.
|
||||||
const needsServerStart = await isPortFree(port);
|
const needsServerStart = await isPortFree(port);
|
||||||
if (needsServerStart && !opts.startServer) {
|
if (needsServerStart && !opts.startServer) {
|
||||||
await fs.rm(saved.path).catch(() => {});
|
await fs.rm(saved.path).catch(() => {});
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (needsServerStart && opts.startServer) {
|
if (needsServerStart && opts.startServer) {
|
||||||
if (!mediaServer) {
|
if (!mediaServer) {
|
||||||
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
||||||
logInfo(
|
logInfo(
|
||||||
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
mediaServer.unref?.();
|
mediaServer.unref?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://${hostname}/media/${saved.id}`;
|
const url = `https://${hostname}/media/${saved.id}`;
|
||||||
return { url, id: saved.id, size: saved.size };
|
return { url, id: saved.id, size: saved.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isPortFree(port: number) {
|
async function isPortFree(port: number) {
|
||||||
try {
|
try {
|
||||||
await ensurePortAvailable(port);
|
await ensurePortAvailable(port);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof PortInUseError) return false;
|
if (err instanceof PortInUseError) return false;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { type MediaKind, mediaKindFromMime } from "./constants.js";
|
||||||
|
|
||||||
|
// Map common mimes to preferred file extensions.
|
||||||
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"audio/ogg": ".ogg",
|
||||||
|
"audio/mpeg": ".mp3",
|
||||||
|
"video/mp4": ".mp4",
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
"text/plain": ".txt",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeHeaderMime(mime?: string | null): string | undefined {
|
||||||
|
if (!mime) return undefined;
|
||||||
|
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
|
||||||
|
return cleaned || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sniffMime(buffer?: Buffer): string | undefined {
|
||||||
|
if (!buffer || buffer.length < 4) return undefined;
|
||||||
|
|
||||||
|
// JPEG: FF D8 FF
|
||||||
|
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (
|
||||||
|
buffer.length >= 8 &&
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer[1] === 0x50 &&
|
||||||
|
buffer[2] === 0x4e &&
|
||||||
|
buffer[3] === 0x47 &&
|
||||||
|
buffer[4] === 0x0d &&
|
||||||
|
buffer[5] === 0x0a &&
|
||||||
|
buffer[6] === 0x1a &&
|
||||||
|
buffer[7] === 0x0a
|
||||||
|
) {
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIF: GIF87a / GIF89a
|
||||||
|
if (buffer.length >= 6) {
|
||||||
|
const sig = buffer.subarray(0, 6).toString("ascii");
|
||||||
|
if (sig === "GIF87a" || sig === "GIF89a") return "image/gif";
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP: RIFF....WEBP
|
||||||
|
if (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
||||||
|
buffer.subarray(8, 12).toString("ascii") === "WEBP"
|
||||||
|
) {
|
||||||
|
return "image/webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF: %PDF-
|
||||||
|
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
|
||||||
|
return "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ogg / Opus: OggS
|
||||||
|
if (buffer.subarray(0, 4).toString("ascii") === "OggS") {
|
||||||
|
return "audio/ogg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP3: ID3 tag or frame sync FF E0+.
|
||||||
|
if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
|
||||||
|
return "audio/mpeg";
|
||||||
|
}
|
||||||
|
if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) {
|
||||||
|
return "audio/mpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP4: "ftyp" at offset 4.
|
||||||
|
if (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.subarray(4, 8).toString("ascii") === "ftyp"
|
||||||
|
) {
|
||||||
|
return "video/mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromPath(filePath?: string): string | undefined {
|
||||||
|
if (!filePath) return undefined;
|
||||||
|
try {
|
||||||
|
if (/^https?:\/\//i.test(filePath)) {
|
||||||
|
const url = new URL(filePath);
|
||||||
|
return path.extname(url.pathname).toLowerCase() || undefined;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall back to plain path parsing
|
||||||
|
}
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return ext || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectMime(opts: {
|
||||||
|
buffer?: Buffer;
|
||||||
|
headerMime?: string | null;
|
||||||
|
filePath?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
const sniffed = sniffMime(opts.buffer);
|
||||||
|
if (sniffed) return sniffed;
|
||||||
|
|
||||||
|
const headerMime = normalizeHeaderMime(opts.headerMime);
|
||||||
|
if (headerMime) return headerMime;
|
||||||
|
|
||||||
|
const ext = extFromPath(opts.filePath);
|
||||||
|
if (ext && MIME_BY_EXT[ext]) return MIME_BY_EXT[ext];
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extensionForMime(mime?: string | null): string | undefined {
|
||||||
|
if (!mime) return undefined;
|
||||||
|
return EXT_BY_MIME[mime.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kindFromMime(mime?: string | null): MediaKind {
|
||||||
|
return mediaKindFromMime(mime);
|
||||||
|
}
|
||||||
+82
-34
@@ -1,53 +1,101 @@
|
|||||||
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
||||||
|
|
||||||
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
// 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) {
|
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) {
|
function cleanCandidate(raw: string) {
|
||||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidMedia(candidate: string) {
|
function isValidMedia(candidate: string) {
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
if (candidate.length > 1024) return false;
|
if (candidate.length > 1024) return false;
|
||||||
if (/\s/.test(candidate)) return false;
|
if (/\s/.test(candidate)) return false;
|
||||||
return (
|
return (
|
||||||
/^https?:\/\//i.test(candidate) ||
|
/^https?:\/\//i.test(candidate) ||
|
||||||
candidate.startsWith("/") ||
|
candidate.startsWith("/") ||
|
||||||
candidate.startsWith("./")
|
candidate.startsWith("./")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitMediaFromOutput(raw: string): {
|
export function splitMediaFromOutput(raw: string): {
|
||||||
text: string;
|
text: string;
|
||||||
mediaUrl?: string;
|
mediaUrls?: string[];
|
||||||
|
mediaUrl?: string; // legacy first item for backward compatibility
|
||||||
} {
|
} {
|
||||||
const trimmedRaw = raw.trim();
|
const trimmedRaw = raw.trim();
|
||||||
const match = MEDIA_TOKEN_RE.exec(trimmedRaw);
|
if (!trimmedRaw) return { text: "" };
|
||||||
if (!match?.[1]) return { text: trimmedRaw };
|
|
||||||
|
|
||||||
const candidate = normalizeMediaSource(cleanCandidate(match[1]));
|
const media: string[] = [];
|
||||||
const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
|
let foundMediaToken = false;
|
||||||
|
|
||||||
const cleanedText = mediaUrl
|
// Collect tokens line by line so we can strip them cleanly.
|
||||||
? trimmedRaw
|
const lines = trimmedRaw.split("\n");
|
||||||
.replace(match[0], "")
|
const keptLines: string[] = [];
|
||||||
.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();
|
|
||||||
|
|
||||||
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);
|
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mock("./store.js", () => ({
|
vi.mock("./store.js", () => ({
|
||||||
getMediaDir: () => MEDIA_DIR,
|
getMediaDir: () => MEDIA_DIR,
|
||||||
cleanOldMedia,
|
cleanOldMedia,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { startMediaServer } = await import("./server.js");
|
const { startMediaServer } = await import("./server.js");
|
||||||
|
|
||||||
describe("media server", () => {
|
describe("media server", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves media and cleans up after send", async () => {
|
it("serves media and cleans up after send", async () => {
|
||||||
const file = path.join(MEDIA_DIR, "file1");
|
const file = path.join(MEDIA_DIR, "file1");
|
||||||
await fs.writeFile(file, "hello");
|
await fs.writeFile(file, "hello");
|
||||||
const server = await startMediaServer(0, 5_000);
|
const server = await startMediaServer(0, 5_000);
|
||||||
const port = (server.address() as AddressInfo).port;
|
const port = (server.address() as AddressInfo).port;
|
||||||
const res = await fetch(`http://localhost:${port}/media/file1`);
|
const res = await fetch(`http://localhost:${port}/media/file1`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.text()).toBe("hello");
|
expect(await res.text()).toBe("hello");
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
await expect(fs.stat(file)).rejects.toThrow();
|
await expect(fs.stat(file)).rejects.toThrow();
|
||||||
await new Promise((r) => server.close(r));
|
await new Promise((r) => server.close(r));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expires old media", async () => {
|
it("expires old media", async () => {
|
||||||
const file = path.join(MEDIA_DIR, "old");
|
const file = path.join(MEDIA_DIR, "old");
|
||||||
await fs.writeFile(file, "stale");
|
await fs.writeFile(file, "stale");
|
||||||
const past = Date.now() - 10_000;
|
const past = Date.now() - 10_000;
|
||||||
await fs.utimes(file, past / 1000, past / 1000);
|
await fs.utimes(file, past / 1000, past / 1000);
|
||||||
const server = await startMediaServer(0, 1_000);
|
const server = await startMediaServer(0, 1_000);
|
||||||
const port = (server.address() as AddressInfo).port;
|
const port = (server.address() as AddressInfo).port;
|
||||||
const res = await fetch(`http://localhost:${port}/media/old`);
|
const res = await fetch(`http://localhost:${port}/media/old`);
|
||||||
expect(res.status).toBe(410);
|
expect(res.status).toBe(410);
|
||||||
await expect(fs.stat(file)).rejects.toThrow();
|
await expect(fs.stat(file)).rejects.toThrow();
|
||||||
await new Promise((r) => server.close(r));
|
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;
|
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
export function attachMediaRoutes(
|
export function attachMediaRoutes(
|
||||||
app: Express,
|
app: Express,
|
||||||
ttlMs = DEFAULT_TTL_MS,
|
ttlMs = DEFAULT_TTL_MS,
|
||||||
_runtime: RuntimeEnv = defaultRuntime,
|
_runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const mediaDir = getMediaDir();
|
const mediaDir = getMediaDir();
|
||||||
|
|
||||||
app.get("/media/:id", async (req, res) => {
|
app.get("/media/:id", async (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const file = path.join(mediaDir, id);
|
const file = path.join(mediaDir, id);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(file);
|
const stat = await fs.stat(file);
|
||||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||||
await fs.rm(file).catch(() => {});
|
await fs.rm(file).catch(() => {});
|
||||||
res.status(410).send("expired");
|
res.status(410).send("expired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.sendFile(file);
|
res.sendFile(file);
|
||||||
// best-effort single-use cleanup after response ends
|
// best-effort single-use cleanup after response ends
|
||||||
res.on("finish", () => {
|
res.on("finish", () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fs.rm(file).catch(() => {});
|
fs.rm(file).catch(() => {});
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
res.status(404).send("not found");
|
res.status(404).send("not found");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// periodic cleanup
|
// periodic cleanup
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
void cleanOldMedia(ttlMs);
|
void cleanOldMedia(ttlMs);
|
||||||
}, ttlMs).unref();
|
}, ttlMs).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMediaServer(
|
export async function startMediaServer(
|
||||||
port: number,
|
port: number,
|
||||||
ttlMs = DEFAULT_TTL_MS,
|
ttlMs = DEFAULT_TTL_MS,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<Server> {
|
): Promise<Server> {
|
||||||
const app = express();
|
const app = express();
|
||||||
attachMediaRoutes(app, ttlMs, runtime);
|
attachMediaRoutes(app, ttlMs, runtime);
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = app.listen(port);
|
const server = app.listen(port);
|
||||||
server.once("listening", () => resolve(server));
|
server.once("listening", () => resolve(server));
|
||||||
server.once("error", (err) => {
|
server.once("error", (err) => {
|
||||||
runtime.error(danger(`Media server failed: ${String(err)}`));
|
runtime.error(danger(`Media server failed: ${String(err)}`));
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+68
-40
@@ -1,60 +1,88 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
||||||
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
||||||
|
|
||||||
vi.mock("node:os", () => ({
|
vi.mock("node:os", () => ({
|
||||||
default: { homedir: () => HOME },
|
default: { homedir: () => HOME },
|
||||||
homedir: () => HOME,
|
homedir: () => HOME,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const store = await import("./store.js");
|
const store = await import("./store.js");
|
||||||
|
|
||||||
describe("media store", () => {
|
describe("media store", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates and returns media directory", async () => {
|
it("creates and returns media directory", async () => {
|
||||||
const dir = await store.ensureMediaDir();
|
const dir = await store.ensureMediaDir();
|
||||||
expect(dir).toContain("warelay-home-test");
|
expect(dir).toContain("warelay-home-test");
|
||||||
const stat = await fs.stat(dir);
|
const stat = await fs.stat(dir);
|
||||||
expect(stat.isDirectory()).toBe(true);
|
expect(stat.isDirectory()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves buffers and enforces size limit", async () => {
|
it("saves buffers and enforces size limit", async () => {
|
||||||
const buf = Buffer.from("hello");
|
const buf = Buffer.from("hello");
|
||||||
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
||||||
const savedStat = await fs.stat(saved.path);
|
const savedStat = await fs.stat(saved.path);
|
||||||
expect(savedStat.size).toBe(buf.length);
|
expect(savedStat.size).toBe(buf.length);
|
||||||
expect(saved.contentType).toBe("text/plain");
|
expect(saved.contentType).toBe("text/plain");
|
||||||
|
expect(saved.path.endsWith(".txt")).toBe(true);
|
||||||
|
|
||||||
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
const jpeg = await sharp({
|
||||||
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
create: { width: 2, height: 2, channels: 3, background: "#123456" },
|
||||||
"Media exceeds 5MB limit",
|
})
|
||||||
);
|
.jpeg({ quality: 80 })
|
||||||
});
|
.toBuffer();
|
||||||
|
const savedJpeg = await store.saveMediaBuffer(jpeg, "image/jpeg");
|
||||||
|
expect(savedJpeg.contentType).toBe("image/jpeg");
|
||||||
|
expect(savedJpeg.path.endsWith(".jpg")).toBe(true);
|
||||||
|
|
||||||
it("copies local files and cleans old media", async () => {
|
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
||||||
const srcFile = path.join(HOME, "tmp-src.txt");
|
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||||
await fs.mkdir(HOME, { recursive: true });
|
"Media exceeds 5MB limit",
|
||||||
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
|
it("copies local files and cleans old media", async () => {
|
||||||
const past = Date.now() - 10_000;
|
const srcFile = path.join(HOME, "tmp-src.txt");
|
||||||
await fs.utimes(saved.path, past / 1000, past / 1000);
|
await fs.mkdir(HOME, { recursive: true });
|
||||||
await store.cleanOldMedia(1);
|
await fs.writeFile(srcFile, "local file");
|
||||||
await expect(fs.stat(saved.path)).rejects.toThrow();
|
const saved = await store.saveMediaSource(srcFile);
|
||||||
});
|
expect(saved.size).toBe(10);
|
||||||
|
const savedStat = await fs.stat(saved.path);
|
||||||
|
expect(savedStat.isFile()).toBe(true);
|
||||||
|
expect(path.extname(saved.path)).toBe(".txt");
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames media based on detected mime even when extension is wrong", async () => {
|
||||||
|
const pngBytes = await sharp({
|
||||||
|
create: { width: 2, height: 2, channels: 3, background: "#00ff00" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const bogusExt = path.join(HOME, "image-wrong.bin");
|
||||||
|
await fs.writeFile(bogusExt, pngBytes);
|
||||||
|
|
||||||
|
const saved = await store.saveMediaSource(bogusExt);
|
||||||
|
expect(saved.contentType).toBe("image/png");
|
||||||
|
expect(path.extname(saved.path)).toBe(".png");
|
||||||
|
|
||||||
|
const buf = await fs.readFile(saved.path);
|
||||||
|
expect(buf.equals(pngBytes)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+124
-81
@@ -6,113 +6,156 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
|
import { detectMime, extensionForMime } from "./mime.js";
|
||||||
|
|
||||||
const MEDIA_DIR = path.join(os.homedir(), ".warelay", "media");
|
const MEDIA_DIR = path.join(os.homedir(), ".warelay", "media");
|
||||||
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
export function getMediaDir() {
|
export function getMediaDir() {
|
||||||
return MEDIA_DIR;
|
return MEDIA_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureMediaDir() {
|
export async function ensureMediaDir() {
|
||||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||||
return MEDIA_DIR;
|
return MEDIA_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
||||||
await ensureMediaDir();
|
await ensureMediaDir();
|
||||||
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(async (file) => {
|
entries.map(async (file) => {
|
||||||
const full = path.join(MEDIA_DIR, file);
|
const full = path.join(MEDIA_DIR, file);
|
||||||
const stat = await fs.stat(full).catch(() => null);
|
const stat = await fs.stat(full).catch(() => null);
|
||||||
if (!stat) return;
|
if (!stat) return;
|
||||||
if (now - stat.mtimeMs > ttlMs) {
|
if (now - stat.mtimeMs > ttlMs) {
|
||||||
await fs.rm(full).catch(() => {});
|
await fs.rm(full).catch(() => {});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeUrl(src: string) {
|
function looksLikeUrl(src: string) {
|
||||||
return /^https?:\/\//i.test(src);
|
return /^https?:\/\//i.test(src);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download media to disk while capturing the first few KB for mime sniffing.
|
||||||
|
*/
|
||||||
async function downloadToFile(
|
async function downloadToFile(
|
||||||
url: string,
|
url: string,
|
||||||
dest: string,
|
dest: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
) {
|
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
|
||||||
await new Promise<void>((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const req = request(url, { headers }, (res) => {
|
const req = request(url, { headers }, (res) => {
|
||||||
if (!res.statusCode || res.statusCode >= 400) {
|
if (!res.statusCode || res.statusCode >= 400) {
|
||||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const out = createWriteStream(dest);
|
const sniffChunks: Buffer[] = [];
|
||||||
res.on("data", (chunk) => {
|
let sniffLen = 0;
|
||||||
total += chunk.length;
|
const out = createWriteStream(dest);
|
||||||
if (total > MAX_BYTES) {
|
res.on("data", (chunk) => {
|
||||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
total += chunk.length;
|
||||||
}
|
if (sniffLen < 16384) {
|
||||||
});
|
sniffChunks.push(chunk);
|
||||||
pipeline(res, out)
|
sniffLen += chunk.length;
|
||||||
.then(() => resolve())
|
}
|
||||||
.catch(reject);
|
if (total > MAX_BYTES) {
|
||||||
});
|
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||||
req.on("error", reject);
|
}
|
||||||
req.end();
|
});
|
||||||
});
|
pipeline(res, out)
|
||||||
|
.then(() => {
|
||||||
|
const sniffBuffer = Buffer.concat(
|
||||||
|
sniffChunks,
|
||||||
|
Math.min(sniffLen, 16384),
|
||||||
|
);
|
||||||
|
const rawHeader = res.headers["content-type"];
|
||||||
|
const headerMime = Array.isArray(rawHeader)
|
||||||
|
? rawHeader[0]
|
||||||
|
: rawHeader;
|
||||||
|
resolve({
|
||||||
|
headerMime,
|
||||||
|
sniffBuffer,
|
||||||
|
size: total,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SavedMedia = {
|
export type SavedMedia = {
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
size: number;
|
size: number;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function saveMediaSource(
|
export async function saveMediaSource(
|
||||||
source: string,
|
source: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
subdir = "",
|
subdir = "",
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await cleanOldMedia();
|
await cleanOldMedia();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const dest = path.join(dir, id);
|
if (looksLikeUrl(source)) {
|
||||||
if (looksLikeUrl(source)) {
|
const tempDest = path.join(dir, `${id}.tmp`);
|
||||||
await downloadToFile(source, dest, headers);
|
const { headerMime, sniffBuffer, size } = await downloadToFile(
|
||||||
const stat = await fs.stat(dest);
|
source,
|
||||||
return { id, path: dest, size: stat.size };
|
tempDest,
|
||||||
}
|
headers,
|
||||||
// local path
|
);
|
||||||
const stat = await fs.stat(source);
|
const mime = detectMime({
|
||||||
if (!stat.isFile()) {
|
buffer: sniffBuffer,
|
||||||
throw new Error("Media path is not a file");
|
headerMime,
|
||||||
}
|
filePath: source,
|
||||||
if (stat.size > MAX_BYTES) {
|
});
|
||||||
throw new Error("Media exceeds 5MB limit");
|
const ext =
|
||||||
}
|
extensionForMime(mime) ?? path.extname(new URL(source).pathname);
|
||||||
await fs.copyFile(source, dest);
|
const finalDest = path.join(dir, ext ? `${id}${ext}` : id);
|
||||||
return { id, path: dest, size: stat.size };
|
await fs.rename(tempDest, finalDest);
|
||||||
|
return { id, path: finalDest, size, contentType: mime };
|
||||||
|
}
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
const buffer = await fs.readFile(source);
|
||||||
|
const mime = detectMime({ buffer, filePath: source });
|
||||||
|
const ext = extensionForMime(mime) ?? path.extname(source);
|
||||||
|
const dest = path.join(dir, ext ? `${id}${ext}` : id);
|
||||||
|
await fs.writeFile(dest, buffer);
|
||||||
|
return { id, path: dest, size: stat.size, contentType: mime };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMediaBuffer(
|
export async function saveMediaBuffer(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType?: string,
|
contentType?: string,
|
||||||
subdir = "inbound",
|
subdir = "inbound",
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
if (buffer.byteLength > MAX_BYTES) {
|
if (buffer.byteLength > MAX_BYTES) {
|
||||||
throw new Error("Media exceeds 5MB limit");
|
throw new Error("Media exceeds 5MB limit");
|
||||||
}
|
}
|
||||||
const dir = path.join(MEDIA_DIR, subdir);
|
const dir = path.join(MEDIA_DIR, subdir);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const dest = path.join(dir, id);
|
const mime = detectMime({ buffer, headerMime: contentType });
|
||||||
await fs.writeFile(dest, buffer);
|
const ext = extensionForMime(mime);
|
||||||
return { id, path: dest, size: buffer.byteLength, contentType };
|
const dest = path.join(dir, ext ? `${id}${ext}` : id);
|
||||||
|
await fs.writeFile(dest, buffer);
|
||||||
|
return { id, path: dest, size: buffer.byteLength, contentType: mime };
|
||||||
}
|
}
|
||||||
@@ -3,53 +3,53 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { enqueueCommand, getQueueSize } from "./command-queue.js";
|
import { enqueueCommand, getQueueSize } from "./command-queue.js";
|
||||||
|
|
||||||
describe("command queue", () => {
|
describe("command queue", () => {
|
||||||
it("runs tasks one at a time in order", async () => {
|
it("runs tasks one at a time in order", async () => {
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let maxActive = 0;
|
let maxActive = 0;
|
||||||
const calls: number[] = [];
|
const calls: number[] = [];
|
||||||
|
|
||||||
const makeTask = (id: number) => async () => {
|
const makeTask = (id: number) => async () => {
|
||||||
active += 1;
|
active += 1;
|
||||||
maxActive = Math.max(maxActive, active);
|
maxActive = Math.max(maxActive, active);
|
||||||
calls.push(id);
|
calls.push(id);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
active -= 1;
|
active -= 1;
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
enqueueCommand(makeTask(1)),
|
enqueueCommand(makeTask(1)),
|
||||||
enqueueCommand(makeTask(2)),
|
enqueueCommand(makeTask(2)),
|
||||||
enqueueCommand(makeTask(3)),
|
enqueueCommand(makeTask(3)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(results).toEqual([1, 2, 3]);
|
expect(results).toEqual([1, 2, 3]);
|
||||||
expect(calls).toEqual([1, 2, 3]);
|
expect(calls).toEqual([1, 2, 3]);
|
||||||
expect(maxActive).toBe(1);
|
expect(maxActive).toBe(1);
|
||||||
expect(getQueueSize()).toBe(0);
|
expect(getQueueSize()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||||
let waited: number | null = null;
|
let waited: number | null = null;
|
||||||
let queuedAhead: number | null = null;
|
let queuedAhead: number | null = null;
|
||||||
|
|
||||||
// First task holds the queue long enough to trigger wait notice.
|
// First task holds the queue long enough to trigger wait notice.
|
||||||
const first = enqueueCommand(async () => {
|
const first = enqueueCommand(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
});
|
});
|
||||||
|
|
||||||
const second = enqueueCommand(async () => {}, {
|
const second = enqueueCommand(async () => {}, {
|
||||||
warnAfterMs: 5,
|
warnAfterMs: 5,
|
||||||
onWait: (ms, ahead) => {
|
onWait: (ms, ahead) => {
|
||||||
waited = ms;
|
waited = ms;
|
||||||
queuedAhead = ahead;
|
queuedAhead = ahead;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
expect(waited).not.toBeNull();
|
expect(waited).not.toBeNull();
|
||||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||||
expect(queuedAhead).toBe(0);
|
expect(queuedAhead).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,57 +2,57 @@
|
|||||||
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
||||||
|
|
||||||
type QueueEntry = {
|
type QueueEntry = {
|
||||||
task: () => Promise<unknown>;
|
task: () => Promise<unknown>;
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
reject: (reason?: unknown) => void;
|
reject: (reason?: unknown) => void;
|
||||||
enqueuedAt: number;
|
enqueuedAt: number;
|
||||||
warnAfterMs: number;
|
warnAfterMs: number;
|
||||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
let draining = false;
|
let draining = false;
|
||||||
|
|
||||||
async function drainQueue() {
|
async function drainQueue() {
|
||||||
if (draining) return;
|
if (draining) return;
|
||||||
draining = true;
|
draining = true;
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const entry = queue.shift() as QueueEntry;
|
const entry = queue.shift() as QueueEntry;
|
||||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||||
if (waitedMs >= entry.warnAfterMs) {
|
if (waitedMs >= entry.warnAfterMs) {
|
||||||
entry.onWait?.(waitedMs, queue.length);
|
entry.onWait?.(waitedMs, queue.length);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await entry.task();
|
const result = await entry.task();
|
||||||
entry.resolve(result);
|
entry.resolve(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entry.reject(err);
|
entry.reject(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
draining = false;
|
draining = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueCommand<T>(
|
export function enqueueCommand<T>(
|
||||||
task: () => Promise<T>,
|
task: () => Promise<T>,
|
||||||
opts?: {
|
opts?: {
|
||||||
warnAfterMs?: number;
|
warnAfterMs?: number;
|
||||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||||
},
|
},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
queue.push({
|
queue.push({
|
||||||
task: () => task(),
|
task: () => task(),
|
||||||
resolve: (value) => resolve(value as T),
|
resolve: (value) => resolve(value as T),
|
||||||
reject,
|
reject,
|
||||||
enqueuedAt: Date.now(),
|
enqueuedAt: Date.now(),
|
||||||
warnAfterMs,
|
warnAfterMs,
|
||||||
onWait: opts?.onWait,
|
onWait: opts?.onWait,
|
||||||
});
|
});
|
||||||
void drainQueue();
|
void drainQueue();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getQueueSize() {
|
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.
|
// Simple promise-wrapped execFile with optional verbosity logging.
|
||||||
export async function runExec(
|
export async function runExec(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
const options =
|
const options =
|
||||||
typeof opts === "number"
|
typeof opts === "number"
|
||||||
? { timeout: opts, encoding: "utf8" as const }
|
? { timeout: opts, encoding: "utf8" as const }
|
||||||
: {
|
: {
|
||||||
timeout: opts.timeoutMs,
|
timeout: opts.timeoutMs,
|
||||||
maxBuffer: opts.maxBuffer,
|
maxBuffer: opts.maxBuffer,
|
||||||
encoding: "utf8" as const,
|
encoding: "utf8" as const,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
if (stdout.trim()) logDebug(stdout.trim());
|
if (stdout.trim()) logDebug(stdout.trim());
|
||||||
if (stderr.trim()) logError(stderr.trim());
|
if (stderr.trim()) logError(stderr.trim());
|
||||||
}
|
}
|
||||||
return { stdout, stderr };
|
return { stdout, stderr };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpawnResult = {
|
export type SpawnResult = {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
code: number | null;
|
code: number | null;
|
||||||
signal: NodeJS.Signals | null;
|
signal: NodeJS.Signals | null;
|
||||||
killed: boolean;
|
killed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandOptions = {
|
export type CommandOptions = {
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCommandWithTimeout(
|
export async function runCommandWithTimeout(
|
||||||
argv: string[],
|
argv: string[],
|
||||||
optionsOrTimeout: number | CommandOptions,
|
optionsOrTimeout: number | CommandOptions,
|
||||||
): Promise<SpawnResult> {
|
): Promise<SpawnResult> {
|
||||||
const options: CommandOptions =
|
const options: CommandOptions =
|
||||||
typeof optionsOrTimeout === "number"
|
typeof optionsOrTimeout === "number"
|
||||||
? { timeoutMs: optionsOrTimeout }
|
? { timeoutMs: optionsOrTimeout }
|
||||||
: optionsOrTimeout;
|
: optionsOrTimeout;
|
||||||
const { timeoutMs, cwd } = options;
|
const { timeoutMs, cwd } = options;
|
||||||
|
|
||||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const child = spawn(argv[0], argv.slice(1), {
|
const child = spawn(argv[0], argv.slice(1), {
|
||||||
stdio: ["inherit", "pipe", "pipe"],
|
stdio: ["inherit", "pipe", "pipe"],
|
||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
child.stdout?.on("data", (d) => {
|
child.stdout?.on("data", (d) => {
|
||||||
stdout += d.toString();
|
stdout += d.toString();
|
||||||
});
|
});
|
||||||
child.stderr?.on("data", (d) => {
|
child.stderr?.on("data", (d) => {
|
||||||
stderr += d.toString();
|
stderr += d.toString();
|
||||||
});
|
});
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
child.on("close", (code, signal) => {
|
child.on("close", (code, signal) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
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,409 +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 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 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
-683
@@ -1,683 +1,32 @@
|
|||||||
import fsSync from "node:fs";
|
// Barrel exports for the web provider pieces. Splitting the original 900+ line
|
||||||
import fs from "node:fs/promises";
|
// module keeps responsibilities small and testable without changing the public API.
|
||||||
import os from "node:os";
|
export {
|
||||||
import path from "node:path";
|
DEFAULT_WEB_MEDIA_BYTES,
|
||||||
import type { proto } from "@whiskeysockets/baileys";
|
HEARTBEAT_PROMPT,
|
||||||
import {
|
HEARTBEAT_TOKEN,
|
||||||
type AnyMessageContent,
|
monitorWebProvider,
|
||||||
DisconnectReason,
|
resolveHeartbeatRecipients,
|
||||||
downloadMediaMessage,
|
runWebHeartbeatOnce,
|
||||||
fetchLatestBaileysVersion,
|
type WebMonitorTuning,
|
||||||
makeCacheableSignalKeyStore,
|
} from "./web/auto-reply.js";
|
||||||
makeWASocket,
|
export {
|
||||||
useMultiFileAuthState,
|
extractMediaPlaceholder,
|
||||||
type WAMessage,
|
extractText,
|
||||||
} from "@whiskeysockets/baileys";
|
monitorWebInbox,
|
||||||
import qrcode from "qrcode-terminal";
|
type WebInboundMessage,
|
||||||
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
type WebListenerCloseReason,
|
||||||
import { waitForever } from "./cli/wait.js";
|
} from "./web/inbound.js";
|
||||||
import { danger, info, isVerbose, logVerbose, success } from "./globals.js";
|
export { loginWeb } from "./web/login.js";
|
||||||
import { logInfo } from "./logger.js";
|
export { loadWebMedia, optimizeImageToJpeg } from "./web/media.js";
|
||||||
import { getChildLogger } from "./logging.js";
|
export { sendMessageWeb } from "./web/outbound.js";
|
||||||
import { saveMediaBuffer } from "./media/store.js";
|
export {
|
||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
createWaSocket,
|
||||||
import type { Provider } from "./utils.js";
|
formatError,
|
||||||
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
|
getStatusCode,
|
||||||
|
logoutWeb,
|
||||||
function formatDuration(ms: number) {
|
logWebSelfId,
|
||||||
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
pickProvider,
|
||||||
}
|
WA_WEB_AUTH_DIR,
|
||||||
|
waitForWaConnection,
|
||||||
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
|
webAuthExists,
|
||||||
|
} from "./web/session.js";
|
||||||
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", "0.1.3"],
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
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)}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
close: async () => {
|
|
||||||
try {
|
|
||||||
sock.ws?.close();
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`Socket close failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function monitorWebProvider(
|
|
||||||
verbose: boolean,
|
|
||||||
listenerFactory = monitorWebInbox,
|
|
||||||
keepAlive = true,
|
|
||||||
replyResolver: typeof getReplyFromConfig = getReplyFromConfig,
|
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
|
||||||
) {
|
|
||||||
const replyLogger = getChildLogger({ module: "web-auto-reply" });
|
|
||||||
// Listen for inbound personal WhatsApp Web messages and auto-reply if configured.
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
void listener.close().finally(() => {
|
|
||||||
logInfo("👋 Web monitor stopped", runtime);
|
|
||||||
runtime.exit(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (keepAlive) {
|
|
||||||
await waitForever();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
export { createClient } from "../../twilio/client.js";
|
export { createClient } from "../../twilio/client.js";
|
||||||
export {
|
export {
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
} from "../../twilio/messages.js";
|
} from "../../twilio/messages.js";
|
||||||
export { monitorTwilio } from "../../twilio/monitor.js";
|
export { monitorTwilio } from "../../twilio/monitor.js";
|
||||||
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
||||||
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
||||||
export { sendTypingIndicator } from "../../twilio/typing.js";
|
export { sendTypingIndicator } from "../../twilio/typing.js";
|
||||||
export {
|
export {
|
||||||
findIncomingNumberSid,
|
findIncomingNumberSid,
|
||||||
findMessagingServiceSid,
|
findMessagingServiceSid,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
} from "../../twilio/update-webhook.js";
|
} from "../../twilio/update-webhook.js";
|
||||||
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.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";
|
import * as entry from "./index.js";
|
||||||
|
|
||||||
describe("providers/web entrypoint", () => {
|
describe("providers/web entrypoint", () => {
|
||||||
it("re-exports web provider helpers", () => {
|
it("re-exports web provider helpers", () => {
|
||||||
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
||||||
expect(entry.loginWeb).toBe(impl.loginWeb);
|
expect(entry.loginWeb).toBe(impl.loginWeb);
|
||||||
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
||||||
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||||
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||||
expect(entry.pickProvider).toBe(impl.pickProvider);
|
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||||
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
||||||
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||||
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||||
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+10
-10
@@ -1,13 +1,13 @@
|
|||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
export {
|
export {
|
||||||
createWaSocket,
|
createWaSocket,
|
||||||
loginWeb,
|
loginWeb,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorWebInbox,
|
monitorWebInbox,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
WA_WEB_AUTH_DIR,
|
WA_WEB_AUTH_DIR,
|
||||||
waitForWaConnection,
|
waitForWaConnection,
|
||||||
webAuthExists,
|
webAuthExists,
|
||||||
} from "../../provider-web.js";
|
} from "../../provider-web.js";
|
||||||
+9
-9
@@ -1,14 +1,14 @@
|
|||||||
export type RuntimeEnv = {
|
export type RuntimeEnv = {
|
||||||
log: typeof console.log;
|
log: typeof console.log;
|
||||||
error: typeof console.error;
|
error: typeof console.error;
|
||||||
exit: (code: number) => never;
|
exit: (code: number) => never;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultRuntime: RuntimeEnv = {
|
export const defaultRuntime: RuntimeEnv = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
error: console.error,
|
error: console.error,
|
||||||
exit: (code) => {
|
exit: (code) => {
|
||||||
process.exit(code);
|
process.exit(code);
|
||||||
throw new Error("unreachable"); // satisfies tests when mocked
|
throw new Error("unreachable"); // satisfies tests when mocked
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2,13 +2,13 @@ import Twilio from "twilio";
|
|||||||
import type { EnvConfig } from "../env.js";
|
import type { EnvConfig } from "../env.js";
|
||||||
|
|
||||||
export function createClient(env: EnvConfig) {
|
export function createClient(env: EnvConfig) {
|
||||||
// Twilio client using either auth token or API key/secret.
|
// Twilio client using either auth token or API key/secret.
|
||||||
if ("authToken" in env.auth) {
|
if ("authToken" in env.auth) {
|
||||||
return Twilio(env.accountSid, env.auth.authToken, {
|
return Twilio(env.accountSid, env.auth.authToken, {
|
||||||
accountSid: env.accountSid,
|
accountSid: env.accountSid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
||||||
accountSid: env.accountSid,
|
accountSid: env.accountSid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { HEARTBEAT_TOKEN } from "../web/auto-reply.js";
|
||||||
|
import { runTwilioHeartbeatOnce } from "./heartbeat.js";
|
||||||
|
|
||||||
|
vi.mock("./send.js", () => ({
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../auto-reply/reply.js", () => ({
|
||||||
|
getReplyFromConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/first
|
||||||
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
// eslint-disable-next-line import/first
|
||||||
|
import { sendMessage } from "./send.js";
|
||||||
|
|
||||||
|
const sendMessageMock = sendMessage as unknown as vi.Mock;
|
||||||
|
const replyResolverMock = getReplyFromConfig as unknown as vi.Mock;
|
||||||
|
|
||||||
|
describe("runTwilioHeartbeatOnce", () => {
|
||||||
|
it("sends manual override body and skips resolver", async () => {
|
||||||
|
sendMessageMock.mockResolvedValue({});
|
||||||
|
await runTwilioHeartbeatOnce({
|
||||||
|
to: "+1555",
|
||||||
|
overrideBody: "hello manual",
|
||||||
|
});
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
|
"+1555",
|
||||||
|
"hello manual",
|
||||||
|
undefined,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(replyResolverMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dry-run manual message avoids sending", async () => {
|
||||||
|
sendMessageMock.mockReset();
|
||||||
|
await runTwilioHeartbeatOnce({
|
||||||
|
to: "+1555",
|
||||||
|
overrideBody: "hello manual",
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
expect(sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(replyResolverMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips send when resolver returns heartbeat token", async () => {
|
||||||
|
replyResolverMock.mockResolvedValue({
|
||||||
|
text: HEARTBEAT_TOKEN,
|
||||||
|
});
|
||||||
|
sendMessageMock.mockReset();
|
||||||
|
await runTwilioHeartbeatOnce({
|
||||||
|
to: "+1555",
|
||||||
|
});
|
||||||
|
expect(sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends resolved heartbeat text when present", async () => {
|
||||||
|
replyResolverMock.mockResolvedValue({
|
||||||
|
text: "ALERT!",
|
||||||
|
});
|
||||||
|
sendMessageMock.mockReset().mockResolvedValue({});
|
||||||
|
await runTwilioHeartbeatOnce({
|
||||||
|
to: "+1555",
|
||||||
|
});
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
|
"+1555",
|
||||||
|
"ALERT!",
|
||||||
|
undefined,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import { danger, success } from "../globals.js";
|
||||||
|
import { logInfo } from "../logger.js";
|
||||||
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../web/auto-reply.js";
|
||||||
|
import { sendMessage } from "./send.js";
|
||||||
|
|
||||||
|
type ReplyResolver = typeof getReplyFromConfig;
|
||||||
|
|
||||||
|
export async function runTwilioHeartbeatOnce(opts: {
|
||||||
|
to: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
replyResolver?: ReplyResolver;
|
||||||
|
overrideBody?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
to,
|
||||||
|
verbose: _verbose = false,
|
||||||
|
runtime = defaultRuntime,
|
||||||
|
overrideBody,
|
||||||
|
dryRun = false,
|
||||||
|
} = opts;
|
||||||
|
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
||||||
|
|
||||||
|
if (overrideBody && overrideBody.trim().length === 0) {
|
||||||
|
throw new Error("Override body must be non-empty when provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (overrideBody) {
|
||||||
|
if (dryRun) {
|
||||||
|
logInfo(
|
||||||
|
`[dry-run] twilio send -> ${to}: ${overrideBody.trim()} (manual message)`,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendMessage(to, overrideBody, undefined, runtime);
|
||||||
|
logInfo(success(`sent manual message to ${to} (twilio)`), runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyResult = await replyResolver(
|
||||||
|
{
|
||||||
|
Body: HEARTBEAT_PROMPT,
|
||||||
|
From: to,
|
||||||
|
To: to,
|
||||||
|
MessageSid: undefined,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!replyResult ||
|
||||||
|
(!replyResult.text &&
|
||||||
|
!replyResult.mediaUrl &&
|
||||||
|
!replyResult.mediaUrls?.length)
|
||||||
|
) {
|
||||||
|
logInfo("heartbeat skipped: empty reply", runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMedia = Boolean(
|
||||||
|
replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const stripped = stripHeartbeatToken(replyResult.text);
|
||||||
|
if (stripped.shouldSkip && !hasMedia) {
|
||||||
|
logInfo(success("heartbeat: ok (HEARTBEAT_OK)"), runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalText = stripped.text || replyResult.text || "";
|
||||||
|
if (dryRun) {
|
||||||
|
logInfo(
|
||||||
|
`[dry-run] heartbeat -> ${to}: ${finalText.slice(0, 200)}`,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendMessage(to, finalText, undefined, runtime);
|
||||||
|
logInfo(success(`heartbeat sent to ${to} (twilio)`), runtime);
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error(danger(`Heartbeat failed: ${String(err)}`));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
-73
@@ -3,97 +3,97 @@ import { withWhatsAppPrefix } from "../utils.js";
|
|||||||
import { createClient } from "./client.js";
|
import { createClient } from "./client.js";
|
||||||
|
|
||||||
export type ListedMessage = {
|
export type ListedMessage = {
|
||||||
sid: string;
|
sid: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
dateCreated: Date | undefined;
|
dateCreated: Date | undefined;
|
||||||
from?: string | null;
|
from?: string | null;
|
||||||
to?: string | null;
|
to?: string | null;
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
errorCode: number | null;
|
errorCode: number | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove duplicates by SID while preserving order.
|
// Remove duplicates by SID while preserving order.
|
||||||
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped: ListedMessage[] = [];
|
const deduped: ListedMessage[] = [];
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (seen.has(m.sid)) continue;
|
if (seen.has(m.sid)) continue;
|
||||||
seen.add(m.sid);
|
seen.add(m.sid);
|
||||||
deduped.push(m);
|
deduped.push(m);
|
||||||
}
|
}
|
||||||
return deduped;
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort messages newest -> oldest by dateCreated.
|
// Sort messages newest -> oldest by dateCreated.
|
||||||
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
||||||
return [...messages].sort((a, b) => {
|
return [...messages].sort((a, b) => {
|
||||||
const da = a.dateCreated?.getTime() ?? 0;
|
const da = a.dateCreated?.getTime() ?? 0;
|
||||||
const db = b.dateCreated?.getTime() ?? 0;
|
const db = b.dateCreated?.getTime() ?? 0;
|
||||||
return db - da;
|
return db - da;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
||||||
export async function listRecentMessages(
|
export async function listRecentMessages(
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
): Promise<ListedMessage[]> {
|
): Promise<ListedMessage[]> {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const client = clientOverride ?? createClient(env);
|
const client = clientOverride ?? createClient(env);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||||
|
|
||||||
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
// 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 fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
||||||
const inbound = await client.messages.list({
|
const inbound = await client.messages.list({
|
||||||
to: from,
|
to: from,
|
||||||
dateSentAfter: since,
|
dateSentAfter: since,
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
});
|
});
|
||||||
const outbound = await client.messages.list({
|
const outbound = await client.messages.list({
|
||||||
from,
|
from,
|
||||||
dateSentAfter: since,
|
dateSentAfter: since,
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||||
const combined = uniqueBySid(
|
const combined = uniqueBySid(
|
||||||
[...inboundArr, ...outboundArr].map((m) => ({
|
[...inboundArr, ...outboundArr].map((m) => ({
|
||||||
sid: m.sid,
|
sid: m.sid,
|
||||||
status: m.status ?? null,
|
status: m.status ?? null,
|
||||||
direction: m.direction ?? null,
|
direction: m.direction ?? null,
|
||||||
dateCreated: m.dateCreated,
|
dateCreated: m.dateCreated,
|
||||||
from: m.from,
|
from: m.from,
|
||||||
to: m.to,
|
to: m.to,
|
||||||
body: m.body,
|
body: m.body,
|
||||||
errorCode: m.errorCode ?? null,
|
errorCode: m.errorCode ?? null,
|
||||||
errorMessage: m.errorMessage ?? 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.
|
// Human-friendly single-line formatter for recent messages.
|
||||||
export function formatMessageLine(m: ListedMessage): string {
|
export function formatMessageLine(m: ListedMessage): string {
|
||||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||||
const dir =
|
const dir =
|
||||||
m.direction === "inbound"
|
m.direction === "inbound"
|
||||||
? "⬅️ "
|
? "⬅️ "
|
||||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||||
? "➡️ "
|
? "➡️ "
|
||||||
: "↔️ ";
|
: "↔️ ";
|
||||||
const status = m.status ?? "unknown";
|
const status = m.status ?? "unknown";
|
||||||
const err =
|
const err =
|
||||||
m.errorCode != null
|
m.errorCode != null
|
||||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||||
: "";
|
: "";
|
||||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||||
const bodyPreview =
|
const bodyPreview =
|
||||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
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";
|
import { monitorTwilio } from "./monitor.js";
|
||||||
|
|
||||||
describe("monitorTwilio", () => {
|
describe("monitorTwilio", () => {
|
||||||
it("processes inbound messages once with injected deps", async () => {
|
it("processes inbound messages once with injected deps", async () => {
|
||||||
const listRecentMessages = vi.fn().mockResolvedValue([
|
const listRecentMessages = vi.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
sid: "m1",
|
sid: "m1",
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
dateCreated: new Date(),
|
dateCreated: new Date(),
|
||||||
from: "+1",
|
from: "+1",
|
||||||
to: "+2",
|
to: "+2",
|
||||||
body: "hi",
|
body: "hi",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
status: null,
|
status: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
||||||
const readEnv = vi.fn(() => ({
|
const readEnv = vi.fn(() => ({
|
||||||
accountSid: "AC",
|
accountSid: "AC",
|
||||||
whatsappFrom: "whatsapp:+1",
|
whatsappFrom: "whatsapp:+1",
|
||||||
auth: { accountSid: "AC", authToken: "t" },
|
auth: { accountSid: "AC", authToken: "t" },
|
||||||
}));
|
}));
|
||||||
const createClient = vi.fn(
|
const createClient = vi.fn(
|
||||||
() => ({ messages: { create: vi.fn() } }) as never,
|
() => ({ messages: { create: vi.fn() } }) as never,
|
||||||
);
|
);
|
||||||
const sleep = vi.fn().mockResolvedValue(undefined);
|
const sleep = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
await monitorTwilio(0, 0, {
|
await monitorTwilio(0, 0, {
|
||||||
deps: {
|
deps: {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
},
|
},
|
||||||
maxIterations: 1,
|
maxIterations: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
||||||
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+96
-96
@@ -8,122 +8,122 @@ import { sleep, withWhatsAppPrefix } from "../utils.js";
|
|||||||
import { createClient } from "./client.js";
|
import { createClient } from "./client.js";
|
||||||
|
|
||||||
type MonitorDeps = {
|
type MonitorDeps = {
|
||||||
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||||
listRecentMessages: (
|
listRecentMessages: (
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
) => Promise<ListedMessage[]>;
|
) => Promise<ListedMessage[]>;
|
||||||
readEnv: typeof readEnv;
|
readEnv: typeof readEnv;
|
||||||
createClient: typeof createClient;
|
createClient: typeof createClient;
|
||||||
sleep: typeof sleep;
|
sleep: typeof sleep;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||||
|
|
||||||
export type ListedMessage = {
|
export type ListedMessage = {
|
||||||
sid: string;
|
sid: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
dateCreated: Date | undefined;
|
dateCreated: Date | undefined;
|
||||||
from?: string | null;
|
from?: string | null;
|
||||||
to?: string | null;
|
to?: string | null;
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
errorCode: number | null;
|
errorCode: number | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MonitorOptions = {
|
type MonitorOptions = {
|
||||||
client?: ReturnType<typeof createClient>;
|
client?: ReturnType<typeof createClient>;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
deps?: MonitorDeps;
|
deps?: MonitorDeps;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultDeps: MonitorDeps = {
|
const defaultDeps: MonitorDeps = {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages: () => Promise.resolve([]),
|
listRecentMessages: () => Promise.resolve([]),
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Poll Twilio for inbound messages and auto-reply when configured.
|
// Poll Twilio for inbound messages and auto-reply when configured.
|
||||||
export async function monitorTwilio(
|
export async function monitorTwilio(
|
||||||
pollSeconds: number,
|
pollSeconds: number,
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
opts?: MonitorOptions,
|
opts?: MonitorOptions,
|
||||||
) {
|
) {
|
||||||
const deps = opts?.deps ?? defaultDeps;
|
const deps = opts?.deps ?? defaultDeps;
|
||||||
const runtime = opts?.runtime ?? defaultRuntime;
|
const runtime = opts?.runtime ?? defaultRuntime;
|
||||||
const maxIterations = opts?.maxIterations ?? Infinity;
|
const maxIterations = opts?.maxIterations ?? Infinity;
|
||||||
let backoffMs = 1_000;
|
let backoffMs = 1_000;
|
||||||
|
|
||||||
const env = deps.readEnv(runtime);
|
const env = deps.readEnv(runtime);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const client = opts?.client ?? deps.createClient(env);
|
const client = opts?.client ?? deps.createClient(env);
|
||||||
logInfo(
|
logInfo(
|
||||||
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
let lastSeenSid: string | undefined;
|
let lastSeenSid: string | undefined;
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
while (iterations < maxIterations) {
|
while (iterations < maxIterations) {
|
||||||
let messages: ListedMessage[] = [];
|
let messages: ListedMessage[] = [];
|
||||||
try {
|
try {
|
||||||
messages =
|
messages =
|
||||||
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||||
backoffMs = 1_000; // reset after success
|
backoffMs = 1_000; // reset after success
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logWarn(
|
logWarn(
|
||||||
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
await deps.sleep(backoffMs);
|
await deps.sleep(backoffMs);
|
||||||
backoffMs = Math.min(backoffMs * 2, 10_000);
|
backoffMs = Math.min(backoffMs * 2, 10_000);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
||||||
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
||||||
const newestFirst = [...inboundOnly].sort(
|
const newestFirst = [...inboundOnly].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
||||||
);
|
);
|
||||||
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
||||||
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||||
iterations += 1;
|
iterations += 1;
|
||||||
if (iterations >= maxIterations) break;
|
if (iterations >= maxIterations) break;
|
||||||
await deps.sleep(
|
await deps.sleep(
|
||||||
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessages(
|
async function handleMessages(
|
||||||
messages: ListedMessage[],
|
messages: ListedMessage[],
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
lastSeenSid: string | undefined,
|
lastSeenSid: string | undefined,
|
||||||
deps: MonitorDeps,
|
deps: MonitorDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (!m.sid) continue;
|
if (!m.sid) continue;
|
||||||
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
||||||
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
||||||
if (m.direction !== "inbound") continue;
|
if (m.direction !== "inbound") continue;
|
||||||
if (!m.from || !m.to) continue;
|
if (!m.from || !m.to) continue;
|
||||||
try {
|
try {
|
||||||
await deps.autoReplyIfConfigured(
|
await deps.autoReplyIfConfigured(
|
||||||
client as unknown as import("./types.js").TwilioRequester & {
|
client as unknown as import("./types.js").TwilioRequester & {
|
||||||
messages: { create: (opts: unknown) => Promise<unknown> };
|
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||||
},
|
},
|
||||||
m as unknown as MessageInstance,
|
m as unknown as MessageInstance,
|
||||||
undefined,
|
undefined,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(danger(`Auto-reply failed: ${String(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";
|
import { waitForFinalStatus } from "./send.js";
|
||||||
|
|
||||||
describe("twilio send helpers", () => {
|
describe("twilio send helpers", () => {
|
||||||
it("waitForFinalStatus resolves on delivered", async () => {
|
it("waitForFinalStatus resolves on delivered", async () => {
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ status: "queued" })
|
.mockResolvedValueOnce({ status: "queued" })
|
||||||
.mockResolvedValueOnce({ status: "delivered" });
|
.mockResolvedValueOnce({ status: "delivered" });
|
||||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||||
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
||||||
expect(fetch).toHaveBeenCalledTimes(2);
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waitForFinalStatus exits on failure", async () => {
|
it("waitForFinalStatus exits on failure", async () => {
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
error: () => {},
|
error: () => {},
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
} as never;
|
} as never;
|
||||||
await expect(
|
await expect(
|
||||||
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
||||||
).rejects.toBeInstanceOf(Error);
|
).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.
|
// Send outbound WhatsApp message; exit non-zero on API failure.
|
||||||
export async function sendMessage(
|
export async function sendMessage(
|
||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
opts?: { mediaUrl?: string },
|
opts?: { mediaUrl?: string },
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
const client = createClient(env);
|
const client = createClient(env);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const toNumber = withWhatsAppPrefix(to);
|
const toNumber = withWhatsAppPrefix(to);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await client.messages.create({
|
const message = await client.messages.create({
|
||||||
from,
|
from,
|
||||||
to: toNumber,
|
to: toNumber,
|
||||||
body,
|
body,
|
||||||
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return { client, sid: message.sid };
|
return { client, sid: message.sid };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTwilioSendError(err, toNumber, runtime);
|
logTwilioSendError(err, toNumber, runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll message status until delivered/failed or timeout.
|
// Poll message status until delivered/failed or timeout.
|
||||||
export async function waitForFinalStatus(
|
export async function waitForFinalStatus(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
sid: string,
|
sid: string,
|
||||||
timeoutSeconds: number,
|
timeoutSeconds: number,
|
||||||
pollSeconds: number,
|
pollSeconds: number,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const m = await client.messages(sid).fetch();
|
const m = await client.messages(sid).fetch();
|
||||||
const status = m.status ?? "unknown";
|
const status = m.status ?? "unknown";
|
||||||
if (successTerminalStatuses.has(status)) {
|
if (successTerminalStatuses.has(status)) {
|
||||||
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (failureTerminalStatuses.has(status)) {
|
if (failureTerminalStatuses.has(status)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
await sleep(pollSeconds * 1000);
|
await sleep(pollSeconds * 1000);
|
||||||
}
|
}
|
||||||
logInfo(
|
logInfo(
|
||||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+45
-45
@@ -4,50 +4,50 @@ import { withWhatsAppPrefix } from "../utils.js";
|
|||||||
import type { TwilioSenderListClient } from "./types.js";
|
import type { TwilioSenderListClient } from "./types.js";
|
||||||
|
|
||||||
export async function findWhatsappSenderSid(
|
export async function findWhatsappSenderSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
from: string,
|
from: string,
|
||||||
explicitSenderSid?: string,
|
explicitSenderSid?: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
||||||
if (explicitSenderSid) {
|
if (explicitSenderSid) {
|
||||||
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
||||||
return explicitSenderSid;
|
return explicitSenderSid;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Prefer official SDK list helper to avoid request-shape mismatches.
|
// Prefer official SDK list helper to avoid request-shape mismatches.
|
||||||
// Twilio helper types are broad; we narrow to expected shape.
|
// Twilio helper types are broad; we narrow to expected shape.
|
||||||
const senderClient = client as unknown as TwilioSenderListClient;
|
const senderClient = client as unknown as TwilioSenderListClient;
|
||||||
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
if (!senders) {
|
if (!senders) {
|
||||||
throw new Error('List senders response missing "senders" array');
|
throw new Error('List senders response missing "senders" array');
|
||||||
}
|
}
|
||||||
const match = senders.find(
|
const match = senders.find(
|
||||||
(s) =>
|
(s) =>
|
||||||
(typeof s.senderId === "string" &&
|
(typeof s.senderId === "string" &&
|
||||||
s.senderId === withWhatsAppPrefix(from)) ||
|
s.senderId === withWhatsAppPrefix(from)) ||
|
||||||
(typeof s.sender_id === "string" &&
|
(typeof s.sender_id === "string" &&
|
||||||
s.sender_id === withWhatsAppPrefix(from)),
|
s.sender_id === withWhatsAppPrefix(from)),
|
||||||
);
|
);
|
||||||
if (!match || typeof match.sid !== "string") {
|
if (!match || typeof match.sid !== "string") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return match.sid;
|
return match.sid;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+51
-51
@@ -1,79 +1,79 @@
|
|||||||
export type TwilioRequestOptions = {
|
export type TwilioRequestOptions = {
|
||||||
method: "get" | "post";
|
method: "get" | "post";
|
||||||
uri: string;
|
uri: string;
|
||||||
params?: Record<string, string | number>;
|
params?: Record<string, string | number>;
|
||||||
form?: Record<string, string>;
|
form?: Record<string, string>;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioSender = { sid: string; sender_id: string };
|
export type TwilioSender = { sid: string; sender_id: string };
|
||||||
|
|
||||||
export type TwilioRequestResponse = {
|
export type TwilioRequestResponse = {
|
||||||
data?: {
|
data?: {
|
||||||
senders?: TwilioSender[];
|
senders?: TwilioSender[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingNumber = {
|
export type IncomingNumber = {
|
||||||
sid: string;
|
sid: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
smsUrl?: string;
|
smsUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioChannelsSender = {
|
export type TwilioChannelsSender = {
|
||||||
sid?: string;
|
sid?: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
sender_id?: string;
|
sender_id?: string;
|
||||||
webhook?: {
|
webhook?: {
|
||||||
callback_url?: string;
|
callback_url?: string;
|
||||||
callback_method?: string;
|
callback_method?: string;
|
||||||
fallback_url?: string;
|
fallback_url?: string;
|
||||||
fallback_method?: string;
|
fallback_method?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelSenderUpdater = {
|
export type ChannelSenderUpdater = {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingPhoneNumberUpdater = {
|
export type IncomingPhoneNumberUpdater = {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingPhoneNumbersClient = {
|
export type IncomingPhoneNumbersClient = {
|
||||||
list: (params: {
|
list: (params: {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}) => Promise<IncomingNumber[]>;
|
}) => Promise<IncomingNumber[]>;
|
||||||
get: (sid: string) => IncomingPhoneNumberUpdater;
|
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||||
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
||||||
|
|
||||||
export type TwilioSenderListClient = {
|
export type TwilioSenderListClient = {
|
||||||
messaging: {
|
messaging: {
|
||||||
v2: {
|
v2: {
|
||||||
channelsSenders: {
|
channelsSenders: {
|
||||||
list: (params: {
|
list: (params: {
|
||||||
channel: string;
|
channel: string;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}) => Promise<TwilioChannelsSender[]>;
|
}) => Promise<TwilioChannelsSender[]>;
|
||||||
(
|
(
|
||||||
sid: string,
|
sid: string,
|
||||||
): ChannelSenderUpdater & {
|
): ChannelSenderUpdater & {
|
||||||
fetch: () => Promise<TwilioChannelsSender>;
|
fetch: () => Promise<TwilioChannelsSender>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
v1: {
|
v1: {
|
||||||
services: (sid: string) => {
|
services: (sid: string) => {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioRequester = {
|
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";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
type TwilioRequestOptions = {
|
type TwilioRequestOptions = {
|
||||||
method: "get" | "post";
|
method: "get" | "post";
|
||||||
uri: string;
|
uri: string;
|
||||||
params?: Record<string, string | number>;
|
params?: Record<string, string | number>;
|
||||||
form?: Record<string, string>;
|
form?: Record<string, string>;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TwilioRequester = {
|
type TwilioRequester = {
|
||||||
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function sendTypingIndicator(
|
export async function sendTypingIndicator(
|
||||||
client: TwilioRequester,
|
client: TwilioRequester,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
messageSid?: string,
|
messageSid?: string,
|
||||||
) {
|
) {
|
||||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||||
if (!messageSid) {
|
if (!messageSid) {
|
||||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.request({
|
await client.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
||||||
form: {
|
form: {
|
||||||
messageId: messageSid,
|
messageId: messageSid,
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,61 +1,61 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findIncomingNumberSid,
|
findIncomingNumberSid,
|
||||||
findMessagingServiceSid,
|
findMessagingServiceSid,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
} from "./update-webhook.js";
|
} from "./update-webhook.js";
|
||||||
|
|
||||||
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
||||||
|
|
||||||
describe("update-webhook helpers", () => {
|
describe("update-webhook helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.TWILIO_ACCOUNT_SID = "AC";
|
process.env.TWILIO_ACCOUNT_SID = "AC";
|
||||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
||||||
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.entries(envBackup).forEach(([k, v]) => {
|
Object.entries(envBackup).forEach(([k, v]) => {
|
||||||
if (v === undefined) delete process.env[k];
|
if (v === undefined) delete process.env[k];
|
||||||
else process.env[k] = v;
|
else process.env[k] = v;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("findIncomingNumberSid returns first match", async () => {
|
it("findIncomingNumberSid returns first match", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const sid = await findIncomingNumberSid(client);
|
const sid = await findIncomingNumberSid(client);
|
||||||
expect(sid).toBe("PN1");
|
expect(sid).toBe("PN1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const sid = await findMessagingServiceSid(client);
|
const sid = await findMessagingServiceSid(client);
|
||||||
expect(sid).toBe("MG1");
|
expect(sid).toBe("MG1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setMessagingServiceWebhook updates via service helper", async () => {
|
it("setMessagingServiceWebhook updates via service helper", async () => {
|
||||||
const update = async (_: unknown) => {};
|
const update = async (_: unknown) => {};
|
||||||
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
||||||
const client = {
|
const client = {
|
||||||
messaging: {
|
messaging: {
|
||||||
v1: {
|
v1: {
|
||||||
services: () => ({ update, fetch }),
|
services: () => ({ update, fetch }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+170
-170
@@ -6,193 +6,193 @@ import type { createClient } from "./client.js";
|
|||||||
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
|
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
|
||||||
|
|
||||||
export async function findIncomingNumberSid(
|
export async function findIncomingNumberSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Look up incoming phone number SID matching the configured WhatsApp number.
|
// Look up incoming phone number SID matching the configured WhatsApp number.
|
||||||
try {
|
try {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
const list = await client.incomingPhoneNumbers.list({
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
return list?.[0]?.sid ?? null;
|
return list?.[0]?.sid ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findMessagingServiceSid(
|
export async function findMessagingServiceSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
||||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||||
try {
|
try {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
const list = await client.incomingPhoneNumbers.list({
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
const msid =
|
const msid =
|
||||||
(list?.[0] as IncomingNumberWithService | undefined)
|
(list?.[0] as IncomingNumberWithService | undefined)
|
||||||
?.messagingServiceSid ?? null;
|
?.messagingServiceSid ?? null;
|
||||||
return msid;
|
return msid;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setMessagingServiceWebhook(
|
export async function setMessagingServiceWebhook(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
url: string,
|
url: string,
|
||||||
method: "POST" | "GET",
|
method: "POST" | "GET",
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const msid = await findMessagingServiceSid(client);
|
const msid = await findMessagingServiceSid(client);
|
||||||
if (!msid) return false;
|
if (!msid) return false;
|
||||||
try {
|
try {
|
||||||
await client.messaging.v1.services(msid).update({
|
await client.messaging.v1.services(msid).update({
|
||||||
InboundRequestUrl: url,
|
InboundRequestUrl: url,
|
||||||
InboundRequestMethod: method,
|
InboundRequestMethod: method,
|
||||||
});
|
});
|
||||||
const fetched = await client.messaging.v1.services(msid).fetch();
|
const fetched = await client.messaging.v1.services(msid).fetch();
|
||||||
const stored = fetched?.inboundRequestUrl;
|
const stored = fetched?.inboundRequestUrl;
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
||||||
export async function updateWebhook(
|
export async function updateWebhook(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
senderSid: string,
|
senderSid: string,
|
||||||
url: string,
|
url: string,
|
||||||
method: "POST" | "GET" = "POST",
|
method: "POST" | "GET" = "POST",
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
// Point Twilio sender webhook at the provided URL.
|
// Point Twilio sender webhook at the provided URL.
|
||||||
const requester = client as unknown as TwilioRequester;
|
const requester = client as unknown as TwilioRequester;
|
||||||
const clientTyped = client as unknown as TwilioSenderListClient;
|
const clientTyped = client as unknown as TwilioSenderListClient;
|
||||||
|
|
||||||
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
||||||
try {
|
try {
|
||||||
await requester.request({
|
await requester.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||||
body: {
|
body: {
|
||||||
webhook: {
|
webhook: {
|
||||||
callback_url: url,
|
callback_url: url,
|
||||||
callback_method: method,
|
callback_method: method,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
if (storedUrl) {
|
if (storedUrl) {
|
||||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b) Form-encoded fallback for older Twilio stacks
|
// 1b) Form-encoded fallback for older Twilio stacks
|
||||||
try {
|
try {
|
||||||
await requester.request({
|
await requester.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||||
form: {
|
form: {
|
||||||
"Webhook.CallbackUrl": url,
|
"Webhook.CallbackUrl": url,
|
||||||
"Webhook.CallbackMethod": method,
|
"Webhook.CallbackMethod": method,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
if (storedUrl) {
|
if (storedUrl) {
|
||||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) SDK helper fallback (if supported by this client)
|
// 2) SDK helper fallback (if supported by this client)
|
||||||
try {
|
try {
|
||||||
if (clientTyped.messaging?.v2?.channelsSenders) {
|
if (clientTyped.messaging?.v2?.channelsSenders) {
|
||||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||||
callbackUrl: url,
|
callbackUrl: url,
|
||||||
callbackMethod: method,
|
callbackMethod: method,
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Incoming phone number fallback (works for many WA senders)
|
// 3) Incoming phone number fallback (works for many WA senders)
|
||||||
try {
|
try {
|
||||||
const phoneSid = await findIncomingNumberSid(clientTyped);
|
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||||
if (phoneSid) {
|
if (phoneSid) {
|
||||||
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
||||||
smsUrl: url,
|
smsUrl: url,
|
||||||
smsMethod: method,
|
smsMethod: method,
|
||||||
});
|
});
|
||||||
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
`❌ 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";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
type TwilioApiError = {
|
type TwilioApiError = {
|
||||||
code?: number | string;
|
code?: number | string;
|
||||||
status?: number | string;
|
status?: number | string;
|
||||||
message?: string;
|
message?: string;
|
||||||
moreInfo?: string;
|
moreInfo?: string;
|
||||||
response?: { body?: unknown };
|
response?: { body?: unknown };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatTwilioError(err: unknown): string {
|
export function formatTwilioError(err: unknown): string {
|
||||||
// Normalize Twilio error objects into a single readable string.
|
// Normalize Twilio error objects into a single readable string.
|
||||||
const e = err as TwilioApiError;
|
const e = err as TwilioApiError;
|
||||||
const pieces = [];
|
const pieces = [];
|
||||||
if (e.code != null) pieces.push(`code ${e.code}`);
|
if (e.code != null) pieces.push(`code ${e.code}`);
|
||||||
if (e.status != null) pieces.push(`status ${e.status}`);
|
if (e.status != null) pieces.push(`status ${e.status}`);
|
||||||
if (e.message) pieces.push(e.message);
|
if (e.message) pieces.push(e.message);
|
||||||
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
||||||
return pieces.length ? pieces.join(" | ") : String(err);
|
return pieces.length ? pieces.join(" | ") : String(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logTwilioSendError(
|
export function logTwilioSendError(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
destination?: string,
|
destination?: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Friendly error logger for send failures, including response body when present.
|
// Friendly error logger for send failures, including response body when present.
|
||||||
const prefix = destination ? `to ${destination}: ` : "";
|
const prefix = destination ? `to ${destination}: ` : "";
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
||||||
);
|
);
|
||||||
const body = (err as TwilioApiError)?.response?.body;
|
const body = (err as TwilioApiError)?.response?.body;
|
||||||
if (body) {
|
if (body) {
|
||||||
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
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 { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
|
||||||
import { type EnvConfig, readEnv } from "../env.js";
|
import { type EnvConfig, readEnv } from "../env.js";
|
||||||
import { danger, success } from "../globals.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 { attachMediaRoutes } from "../media/server.js";
|
||||||
import { saveMediaSource } from "../media/store.js";
|
import { saveMediaSource } from "../media/store.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.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. */
|
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
||||||
export async function startWebhook(
|
export async function startWebhook(
|
||||||
port: number,
|
port: number,
|
||||||
path = "/webhook/whatsapp",
|
path = "/webhook/whatsapp",
|
||||||
autoReply: string | undefined,
|
autoReply: string | undefined,
|
||||||
verbose: boolean,
|
verbose: boolean,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<Server> {
|
): Promise<Server> {
|
||||||
const normalizedPath = normalizePath(path);
|
const normalizedPath = normalizePath(path);
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
attachMediaRoutes(app, undefined, runtime);
|
attachMediaRoutes(app, undefined, runtime);
|
||||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||||
app.use(bodyParser.urlencoded({ extended: false }));
|
app.use(bodyParser.urlencoded({ extended: false }));
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||||
runtime.log(`
|
runtime.log(`
|
||||||
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
[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);
|
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||||
let mediaPath: string | undefined;
|
let mediaPath: string | undefined;
|
||||||
let mediaUrlInbound: string | undefined;
|
let mediaUrlInbound: string | undefined;
|
||||||
let mediaType: string | undefined;
|
let mediaType: string | undefined;
|
||||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||||
mediaType =
|
mediaType =
|
||||||
typeof req.body?.MediaContentType0 === "string"
|
typeof req.body?.MediaContentType0 === "string"
|
||||||
? (req.body.MediaContentType0 as string)
|
? (req.body.MediaContentType0 as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const creds = buildTwilioBasicAuth(env);
|
const creds = buildTwilioBasicAuth(env);
|
||||||
const saved = await saveMediaSource(
|
const saved = await saveMediaSource(
|
||||||
mediaUrlInbound,
|
mediaUrlInbound,
|
||||||
{
|
{
|
||||||
Authorization: `Basic ${creds}`,
|
Authorization: `Basic ${creds}`,
|
||||||
},
|
},
|
||||||
"inbound",
|
"inbound",
|
||||||
);
|
);
|
||||||
mediaPath = saved.path;
|
mediaPath = saved.path;
|
||||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(`Failed to download inbound media: ${String(err)}`),
|
danger(`Failed to download inbound media: ${String(err)}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createClient(env);
|
const client = createClient(env);
|
||||||
let replyResult: ReplyPayload | undefined =
|
let replyResult: ReplyPayload | undefined =
|
||||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||||
if (!replyResult) {
|
if (!replyResult) {
|
||||||
replyResult = await getReplyFromConfig(
|
replyResult = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body,
|
Body,
|
||||||
From,
|
From,
|
||||||
To,
|
To,
|
||||||
MessageSid,
|
MessageSid,
|
||||||
MediaPath: mediaPath,
|
MediaPath: mediaPath,
|
||||||
MediaUrl: mediaUrlInbound,
|
MediaUrl: mediaUrlInbound,
|
||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||||
try {
|
try {
|
||||||
let mediaUrl = replyResult.mediaUrl;
|
let mediaUrl = replyResult.mediaUrl;
|
||||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||||
const hosted = await ensureMediaHosted(mediaUrl);
|
const hosted = await mediaHost.ensureMediaHosted(mediaUrl);
|
||||||
mediaUrl = hosted.url;
|
mediaUrl = hosted.url;
|
||||||
}
|
}
|
||||||
await client.messages.create({
|
await client.messages.create({
|
||||||
from: To,
|
from: To,
|
||||||
to: From,
|
to: From,
|
||||||
body: replyResult.text ?? "",
|
body: replyResult.text ?? "",
|
||||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||||
});
|
});
|
||||||
if (verbose)
|
if (verbose)
|
||||||
runtime.log(
|
runtime.log(
|
||||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTwilioSendError(err, From ?? undefined, runtime);
|
logTwilioSendError(err, From ?? undefined, runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respond 200 OK to Twilio.
|
// Respond 200 OK to Twilio.
|
||||||
res.type("text/xml").send("<Response></Response>");
|
res.type("text/xml").send("<Response></Response>");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||||
res.status(404).send("warelay webhook: not found");
|
res.status(404).send("warelay webhook: not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server and resolve once listening; reject on bind error.
|
// Start server and resolve once listening; reject on bind error.
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = app.listen(port);
|
const server = app.listen(port);
|
||||||
|
|
||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||||
);
|
);
|
||||||
resolve(server);
|
resolve(server);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onError = (err: NodeJS.ErrnoException) => {
|
const onError = (err: NodeJS.ErrnoException) => {
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(err);
|
reject(err);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
server.off("listening", onListening);
|
server.off("listening", onListening);
|
||||||
server.off("error", onError);
|
server.off("error", onError);
|
||||||
};
|
};
|
||||||
|
|
||||||
server.once("listening", onListening);
|
server.once("listening", onListening);
|
||||||
server.once("error", onError);
|
server.once("error", onError);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTwilioBasicAuth(env: EnvConfig) {
|
function buildTwilioBasicAuth(env: EnvConfig) {
|
||||||
if ("authToken" in env.auth) {
|
if ("authToken" in env.auth) {
|
||||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
||||||
"base64",
|
"base64",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
||||||
"base64",
|
"base64",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+61
-43
@@ -3,67 +3,85 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
assertProvider,
|
assertProvider,
|
||||||
ensureDir,
|
CONFIG_DIR,
|
||||||
normalizeE164,
|
ensureDir,
|
||||||
normalizePath,
|
jidToE164,
|
||||||
sleep,
|
normalizeE164,
|
||||||
toWhatsappJid,
|
normalizePath,
|
||||||
withWhatsAppPrefix,
|
sleep,
|
||||||
|
toWhatsappJid,
|
||||||
|
withWhatsAppPrefix,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
|
|
||||||
describe("normalizePath", () => {
|
describe("normalizePath", () => {
|
||||||
it("adds leading slash when missing", () => {
|
it("adds leading slash when missing", () => {
|
||||||
expect(normalizePath("foo")).toBe("/foo");
|
expect(normalizePath("foo")).toBe("/foo");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps existing slash", () => {
|
it("keeps existing slash", () => {
|
||||||
expect(normalizePath("/bar")).toBe("/bar");
|
expect(normalizePath("/bar")).toBe("/bar");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("withWhatsAppPrefix", () => {
|
describe("withWhatsAppPrefix", () => {
|
||||||
it("adds whatsapp prefix", () => {
|
it("adds whatsapp prefix", () => {
|
||||||
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
|
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves prefixed intact", () => {
|
it("leaves prefixed intact", () => {
|
||||||
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
|
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ensureDir", () => {
|
describe("ensureDir", () => {
|
||||||
it("creates nested directory", async () => {
|
it("creates nested directory", async () => {
|
||||||
const tmp = await fs.promises.mkdtemp(
|
const tmp = await fs.promises.mkdtemp(
|
||||||
path.join(os.tmpdir(), "warelay-test-"),
|
path.join(os.tmpdir(), "warelay-test-"),
|
||||||
);
|
);
|
||||||
const target = path.join(tmp, "nested", "dir");
|
const target = path.join(tmp, "nested", "dir");
|
||||||
await ensureDir(target);
|
await ensureDir(target);
|
||||||
expect(fs.existsSync(target)).toBe(true);
|
expect(fs.existsSync(target)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sleep", () => {
|
describe("sleep", () => {
|
||||||
it("resolves after delay using fake timers", async () => {
|
it("resolves after delay using fake timers", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const promise = sleep(1000);
|
const promise = sleep(1000);
|
||||||
vi.advanceTimersByTime(1000);
|
vi.advanceTimersByTime(1000);
|
||||||
await expect(promise).resolves.toBeUndefined();
|
await expect(promise).resolves.toBeUndefined();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assertProvider", () => {
|
describe("assertProvider", () => {
|
||||||
it("throws for invalid provider", () => {
|
it("throws for invalid provider", () => {
|
||||||
expect(() => assertProvider("bad" as string)).toThrow();
|
expect(() => assertProvider("bad" as string)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeE164 & toWhatsappJid", () => {
|
describe("normalizeE164 & toWhatsappJid", () => {
|
||||||
it("strips formatting and prefixes", () => {
|
it("strips formatting and prefixes", () => {
|
||||||
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
||||||
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
|
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
|
||||||
"5551234567@s.whatsapp.net",
|
"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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user