Compare commits
102 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 | |||
| 9c25e15e92 | |||
| bcbf0de240 | |||
| 1ef7f4dbad | |||
| 9db969e94e | |||
| 4f27f4cf79 | |||
| b7c46909cd | |||
| 953f9af419 | |||
| 6ea32873df |
@@ -21,6 +21,7 @@
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- 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.
|
||||
- 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
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
|
||||
+60
-5
@@ -1,5 +1,65 @@
|
||||
# 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
|
||||
|
||||
### Features
|
||||
- Added `cwd` option to command reply config for setting the working directory where commands execute. Essential for Claude Code to have proper project context.
|
||||
- Added configurable file-based logging (default `/tmp/warelay/warelay.log`) with log level set via `logging.level` in `~/.warelay/warelay.json`; verbose still forces debug.
|
||||
|
||||
### Developer notes
|
||||
- Command auto-replies now pass `{ timeoutMs, cwd }` into the command runner; custom runners/tests that stub `runCommandWithTimeout` should accept the options object as well as the legacy numeric timeout.
|
||||
|
||||
## 0.1.2 — 2025-11-25
|
||||
|
||||
### CI/build fix
|
||||
- Fixed commander help configuration (`subcommandTerm`) so TypeScript builds pass in CI.
|
||||
|
||||
## 0.1.1 — 2025-11-25
|
||||
|
||||
### CLI polish
|
||||
@@ -8,11 +68,6 @@
|
||||
- `send` and `status` gained a `--verbose` flag for consistent noisy output when debugging.
|
||||
- Lowercased branding in docs/UA; web provider UA is `warelay/cli/0.1.1`.
|
||||
|
||||
## 0.1.2 — 2025-11-25
|
||||
|
||||
### CI/build fix
|
||||
- Fixed commander help configuration (`subcommandTerm`) so TypeScript builds pass in CI.
|
||||
- Bumped version/UA to 0.1.2; no functional changes beyond the CI fix.
|
||||
|
||||
## 0.1.0 — 2025-11-25
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,17 +1,30 @@
|
||||
# 📡 warelay — Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.
|
||||
[](https://github.com/steipete/warelay/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/warelay)
|
||||
[](LICENSE)
|
||||
# 📡 warelay — Send, receive, and auto-reply on WhatsApp.
|
||||
|
||||
<p align="center">
|
||||
<img src="README-header.png" alt="warelay header" width="640">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/steipete/warelay/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/warelay/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://www.npmjs.com/package/warelay"><img src="https://img.shields.io/npm/v/warelay.svg?style=for-the-badge" alt="npm version"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
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)
|
||||
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
|
||||
|
||||
**A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)**
|
||||
1. Link your account: `warelay login` (scan the QR).
|
||||
2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"` (add `--provider web` if you want to force the web session).
|
||||
3. Stay online & auto-reply: `warelay relay --verbose` (defaults to Web when logged in, falls back to Twilio otherwise).
|
||||
3. Stay online & auto-reply: `warelay relay --verbose` (uses Web when you're logged in; if you're not linked, start it with `--provider twilio`). When a Web session drops, the relay exits instead of silently falling back so you notice and re-login.
|
||||
|
||||
**B) Twilio WhatsApp number (for delivery status + webhooks)**
|
||||
1. Copy `.env.example` → `.env`; set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` **or** `TWILIO_API_KEY`/`TWILIO_API_SECRET`, and `TWILIO_WHATSAPP_FROM=whatsapp:+19995550123` (optional `TWILIO_SENDER_SID`).
|
||||
@@ -36,18 +49,47 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
|
||||
| `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to <e164>` `--message <text>` `--wait <sec>` `--poll <sec>` `--provider twilio\|web` `--json` `--dry-run` `--verbose` |
|
||||
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
|
||||
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
|
||||
| `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider <auto\|web>` `--to <e164?>` `--session-id <uuid?>` `--all` `--verbose` |
|
||||
| `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider <auto\|web>` `--verbose` |
|
||||
| `warelay relay:heartbeat:tmux` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ |
|
||||
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
||||
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
||||
|
||||
### Sending images
|
||||
- Twilio: `warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media` (needs `warelay webhook --ingress tailscale` or `--serve-media` to auto-host via Funnel; max 5 MB).
|
||||
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed).
|
||||
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present).
|
||||
### Sending media
|
||||
- Twilio: `warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media` (needs `warelay webhook --ingress tailscale` or `--serve-media` to auto-host via Funnel; max 5 MB per file because of the built-in host).
|
||||
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed). Web auto-detects media kind: images (≤6 MB), audio/voice or video (≤16 MB), other docs (≤100 MB). Images are resized to max 2048px and JPEG recompressed when the cap would be exceeded.
|
||||
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present). Web auto-replies honor `inbound.reply.mediaMaxMb` (default 5 MB) as a post-compression target but will never exceed the provider hard limits above.
|
||||
|
||||
### Voice notes (optional transcription)
|
||||
- If you set `inbound.transcribeAudio.command`, warelay will run that CLI when inbound audio arrives (e.g., WhatsApp voice notes) and replace the Body with the transcript before templating/Claude.
|
||||
- Example using OpenAI Whisper CLI (requires `OPENAI_API_KEY`):
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
transcribeAudio: {
|
||||
command: [
|
||||
"openai",
|
||||
"api",
|
||||
"audio.transcriptions.create",
|
||||
"-m",
|
||||
"whisper-1",
|
||||
"-f",
|
||||
"{{MediaPath}}",
|
||||
"--response-format",
|
||||
"text"
|
||||
],
|
||||
timeoutSeconds: 45
|
||||
},
|
||||
reply: { mode: "command", command: ["claude", "{{Body}}"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
- Works for Web and Twilio providers; verbose mode logs when transcription runs. The command prompt includes the original media path plus a `Transcript:` block so models see both. If transcription fails, the original Body is used.
|
||||
|
||||
## Providers
|
||||
- **Twilio (default):** needs `.env` creds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators.
|
||||
- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out).
|
||||
- **Auto-select (`relay` only):** `--provider auto` uses Web when logged in, otherwise Twilio polling.
|
||||
- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out). If the Web socket closes, the relay exits instead of pivoting to Twilio.
|
||||
- **Auto-select (`relay` only):** `--provider auto` picks Web when a cache exists at start, otherwise Twilio polling. It will not swap from Web to Twilio mid-run if the Web session drops.
|
||||
|
||||
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
|
||||
|
||||
@@ -78,22 +120,43 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
||||
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
|
||||
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
|
||||
claudeOutputFormat: "text",
|
||||
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 }
|
||||
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 },
|
||||
heartbeatMinutes: 10 // optional; pings Claude every 10m with "HEARTBEAT ultrathink" and only sends if it omits HEARTBEAT_OK
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Heartbeat pings (command mode)
|
||||
- When `heartbeatMinutes` is set (default 10 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt.
|
||||
- Heartbeat body is `HEARTBEAT ultrathink` (so the model can recognize the probe); if Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran.
|
||||
- Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally.
|
||||
- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `--session-id <uuid>` to force resuming a specific Claude session, `--all` to ping every active session, `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:heartbeat:tmux`.
|
||||
- When multiple active sessions exist, `warelay heartbeat` requires `--to <E.164>` or `--all`; if `allowFrom` is just `"*"`, you must choose a target with one of those flags.
|
||||
|
||||
### Logging (optional)
|
||||
- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing.
|
||||
- Override in `~/.warelay/warelay.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
logging: {
|
||||
level: "warn",
|
||||
file: "/tmp/warelay/custom.log"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude CLI setup (how we run it)
|
||||
1) Install the official Claude CLI (e.g., `brew install anthropic-ai/cli/claude` or follow the Anthropic docs) and run `claude login` so it can read your API key.
|
||||
2) In `warelay.json`, set `reply.mode` to `"command"` and point `command[0]` to `"claude"`; set `claudeOutputFormat` to `"text"` (or `"json"`/`"stream-json"` if you want warelay to parse and trim the JSON output).
|
||||
3) (Optional) Add `bodyPrefix` to inject a system prompt and `session` settings to keep multi-turn context (`/new` resets by default).
|
||||
3) (Optional) Add `bodyPrefix` to inject a system prompt and `session` settings to keep multi-turn context (`/new` resets by default). Set `sendSystemOnce: true` (plus an optional `sessionIntro`) to only send that prompt on the first turn of each session.
|
||||
4) Run `pnpm warelay relay --provider auto` (or `--provider web|twilio`) and send a WhatsApp message; warelay will queue the Claude call, stream typing indicators (Twilio provider), parse the result, and send back the text.
|
||||
|
||||
### Auto-reply parameter table (compact)
|
||||
| Key | Type & default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`). |
|
||||
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`); `"*"` allows any sender. |
|
||||
| `inbound.reply.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
|
||||
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
|
||||
| `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. |
|
||||
@@ -105,6 +168,9 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
||||
| `inbound.reply.session.resetTriggers` | `string[]` (default: `["/new"]`) | Exact match or prefix (`/new hi`) resets session. |
|
||||
| `inbound.reply.session.idleMinutes` | `number` (default: `60`) | Session expires after idle period. |
|
||||
| `inbound.reply.session.store` | `string` (default: `~/.warelay/sessions.json`) | Custom session store path. |
|
||||
| `inbound.reply.session.sendSystemOnce` | `boolean` (default: `false`) | If `true`, only include the system prompt/template on the first turn of a session. |
|
||||
| `inbound.reply.session.sessionIntro` | `string` | Optional intro text sent once per new session (prepended before the body when `sendSystemOnce` is used). |
|
||||
| `inbound.reply.typingIntervalSeconds` | `number` (default: `8` for command replies) | How often to refresh typing indicators while the command/Claude run is in flight. |
|
||||
| `inbound.reply.session.sessionArgNew` | `string[]` (default: `["--session-id","{{SessionId}}"]`) | Args injected for a new session run. |
|
||||
| `inbound.reply.session.sessionArgResume` | `string[]` (default: `["--resume","{{SessionId}}"]`) | Args for resumed sessions. |
|
||||
| `inbound.reply.session.sessionArgBeforeBody` | `boolean` (default: `true`) | Place session args before final body arg. |
|
||||
@@ -122,9 +188,14 @@ Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{Mess
|
||||
- Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`.
|
||||
- Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
|
||||
|
||||
## FAQ & Safety (quick answers)
|
||||
### Maintainer notes (web provider internals)
|
||||
- Web logic lives under `src/web/`: `session.ts` (auth/cache + provider pick), `login.ts` (QR login/logout), `outbound.ts`/`inbound.ts` (send/receive plumbing), `auto-reply.ts` (relay loop + reconnect/backoff), `media.ts` (download/resize helpers), and `reconnect.ts` (shared retry math). `test-helpers.ts` provides fixtures.
|
||||
- The public surface remains the `src/provider-web.ts` barrel so existing imports keep working.
|
||||
- Reconnects are capped and logged; no Twilio fallback occurs after a Web disconnect—restart the relay after re-linking.
|
||||
|
||||
## FAQ & Safety
|
||||
- Twilio errors: **63016 “permission to send an SMS has not been enabled”** → ensure your number is WhatsApp-enabled; **63007 template not approved** → send a free-form session message within 24h or use an approved template; **63112 policy violation** → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run `pnpm warelay status` to see the exact Twilio response body.
|
||||
- Does this store my messages? warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs print to stdout/stderr; redirect or rotate if needed.
|
||||
- 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`).
|
||||
- Personal WhatsApp safety: Automation on personal accounts can be rate-limited or logged out by WhatsApp. Use `--provider web` sparingly, keep messages human-like, and re-run `login` if the session is dropped.
|
||||
- Limits to remember: WhatsApp text limit ~1600 chars; avoid rapid bursts—space sends by a few seconds; keep webhook replies under a couple seconds for good UX; command auto-replies time out after 600s by default.
|
||||
- Deploy / keep running: Use `tmux` or `screen` for ad-hoc (`tmux new -s warelay -- pnpm warelay relay --provider twilio`). For long-running hosts, wrap `pnpm warelay relay ...` or `pnpm warelay webhook --ingress tailscale ...` in a systemd service or macOS LaunchAgent; ensure environment variables are loaded in that context.
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentWidth": 2
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
+1
-1
@@ -30,6 +30,6 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
|
||||
6) **Post-publish**
|
||||
- [ ] 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.
|
||||
- [ ] 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,90 +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",
|
||||
// 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:
|
||||
- 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
|
||||
- Allow sending an image with an optional caption via `warelay send` for both providers.
|
||||
- Allow auto-replies (Twilio webhook, Twilio poller, Web inbox) to return an image (optionally with text) when configured.
|
||||
- For the Web provider, also support audio/voice, video, and generic documents with sensible per-type limits.
|
||||
- Keep the “one command at a time” queue intact; media fetch/serve must not block other replies longer than necessary.
|
||||
- Avoid introducing new external services: reuse the existing Tailscale Funnel port to host media for Twilio.
|
||||
|
||||
@@ -21,9 +22,13 @@ This document defines how `warelay` should handle sending and replying with imag
|
||||
## Provider Behavior
|
||||
### Web (Baileys)
|
||||
- Input: local file path **or** HTTP(S) URL.
|
||||
- Flow: load into Buffer (max 5 MB), send via `sock.sendMessage(jid, { image: buffer, caption })`.
|
||||
- Flow: load into Buffer, detect media kind, and apply the right payload:
|
||||
- Images: **resize + recompress to JPEG** (max side 2048px, quality step-down) to fit under `inbound.reply.mediaMaxMb` (default 5 MB) but never above the Web hard cap (6 MB).
|
||||
- Audio/voice and video: pass through up to 16 MB; set `ptt: true` for audio to send as a voice note.
|
||||
- Everything else becomes a document with filename, up to 100 MB.
|
||||
- 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.
|
||||
- Logging: non-verbose shows `↩️`/`✅` with caption; verbose includes `(media, <bytes>B, <ms>ms fetch)`.
|
||||
- Logging: non-verbose shows `↩️`/`✅` with caption; verbose includes `(media, <bytes>B, <ms>ms fetch)` and the local/remote path.
|
||||
|
||||
### Twilio
|
||||
- Twilio API requires a public HTTPS `MediaUrl`; it will not accept local paths.
|
||||
@@ -31,7 +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`.
|
||||
- `MediaUrl` = `https://<tailnet-host>.ts.net/media/<uuid>`.
|
||||
- Files auto-removed after TTL (default 2 minutes) or after first successful fetch (best-effort).
|
||||
- Enforce size limit 5 MB; reject early with clear error.
|
||||
- Enforce size limit 5 MB (matches the media host guard); reject early with clear error.
|
||||
- If `--media` is already an HTTPS URL, pass through unchanged.
|
||||
- Fallback: if Funnel is not enabled (or host unknown) and a local path is provided, fail with guidance to run `warelay webhook --ingress tailscale` (or pass a URL instead).
|
||||
|
||||
@@ -41,7 +46,7 @@ This document defines how `warelay` should handle sending and replying with imag
|
||||
- 404/410 if expired or missing.
|
||||
- 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.
|
||||
- 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
|
||||
- `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:
|
||||
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
|
||||
- `{{MediaPath}}` local temp path written before running the command.
|
||||
- Size guard: only download if ≤5 MB; else skip and log.
|
||||
- Size guard: only download if ≤5 MB; else skip and log (aligns with the temp media store limit).
|
||||
- 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
|
||||
- Local path with twilio + Funnel disabled → error: “Twilio media needs a public URL; start `warelay webhook --ingress tailscale` or pass an https:// URL.”
|
||||
- File too large (>5 MB) → “Media exceeds 5 MB limit; resize or host elsewhere.”
|
||||
- File too large → error mentions the applicable cap (5 MB for Twilio host, 6/16/100 MB for Web image/audio-video/doc respectively).
|
||||
- Download failure for web provider → “Failed to load media from <source>; skipping send.”
|
||||
|
||||
## Tests to Add
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Web Relay Troubleshooting (Nov 26, 2025)
|
||||
|
||||
## Symptoms & quick fixes
|
||||
- **Stream Errored / Conflict / status 409–515:** WhatsApp closed the socket because another session is active or creds went stale. Run `warelay logout` then `warelay login --provider web` and restart the relay.
|
||||
- **Logged out:** Console prints “session logged out”; re-link with `warelay login --provider web`.
|
||||
- **Repeated retries then exit:** Reconnects are capped (default 12 attempts). Tune with `--web-retries`, `--web-retry-initial`, `--web-retry-max`, or config `web.reconnect`.
|
||||
- **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection.
|
||||
|
||||
## Helpful commands
|
||||
- Start relay web-only: `pnpm warelay relay --provider web --verbose`
|
||||
- Show who is linked: `pnpm warelay relay --provider web --verbose` (first line prints the linked E.164)
|
||||
- Logout (clear creds): `pnpm warelay logout`
|
||||
- Relink: `pnpm warelay login --provider web`
|
||||
- Tail logs (default): `tail -f /tmp/warelay/warelay.log`
|
||||
|
||||
## Reading the logs
|
||||
- `web-reconnect`: close reasons, retry/backoff, max-attempt exit.
|
||||
- `web-heartbeat`: connectionId, messagesHandled, authAgeMs, uptimeMs (every 60s by default).
|
||||
- `web-auto-reply`: inbound/outbound message records with correlation IDs.
|
||||
|
||||
## When to tweak knobs
|
||||
- High churn networks: increase `web.reconnect.maxAttempts` or `--web-retries`.
|
||||
- Slow links: raise `--web-retry-max` to give more headroom before bailing.
|
||||
- Chatty monitors: increase `--web-heartbeat` interval if log volume is high.
|
||||
|
||||
## If it keeps failing
|
||||
1) `warelay logout` → `warelay login --provider web` (fresh QR link).
|
||||
2) Ensure no other device/browser is using the same WA Web session.
|
||||
3) Check WhatsApp mobile app is online and not in low-power mode.
|
||||
4) If status is 515, let the client restart once after pairing (already handled automatically).
|
||||
5) Capture the last `web-reconnect` entry and the status code before escalating.
|
||||
@@ -0,0 +1,17 @@
|
||||
# tmux helpers (relay backgrounding)
|
||||
|
||||
## Why we ship tmux helpers
|
||||
- Run the relay detached so your shell can close, while keeping an interactive pane you can reattach to.
|
||||
- Provide a consistent start/attach workflow without adding a daemon mode or external process manager.
|
||||
- Keep the relay code itself tmux-agnostic; tmux is only a launcher concern.
|
||||
|
||||
## Commands
|
||||
- `warelay relay:tmux` — restarts the `warelay-relay` session running `pnpm warelay relay --verbose`, then attaches (skips attach when stdout isn’t a TTY).
|
||||
- `warelay relay:tmux:attach` — attach to the existing session without restarting it.
|
||||
- `warelay relay:heartbeat:tmux` — same as `relay:tmux` but adds `--heartbeat-now` so Claude is pinged immediately on startup.
|
||||
|
||||
All helpers use the fixed session name `warelay-relay`.
|
||||
|
||||
## Logs
|
||||
- The relay always writes to the configured file logger (defaults to `/tmp/warelay/warelay.log`); on start it prints the active log path and level.
|
||||
- tmux is just for interactive viewing; you can also tail the log file or use another supervisor if you prefer.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "warelay",
|
||||
"version": "0.1.2",
|
||||
"version": "1.2.2",
|
||||
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -40,6 +40,7 @@
|
||||
"json5": "^2.2.3",
|
||||
"pino": "^10.1.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.33.5",
|
||||
"twilio": "^5.10.6",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@whiskeysockets/baileys'
|
||||
- sharp
|
||||
@@ -3,37 +3,37 @@ import { describe, expect, it } from "vitest";
|
||||
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
|
||||
|
||||
describe("claude JSON parsing", () => {
|
||||
it("extracts text from single JSON object", () => {
|
||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||
expect(out).toBe("hello");
|
||||
});
|
||||
it("extracts text from single JSON object", () => {
|
||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||
expect(out).toBe("hello");
|
||||
});
|
||||
|
||||
it("extracts from newline-delimited JSON", () => {
|
||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||
expect(out).toBe("there");
|
||||
});
|
||||
it("extracts from newline-delimited JSON", () => {
|
||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||
expect(out).toBe("there");
|
||||
});
|
||||
|
||||
it("returns undefined on invalid JSON", () => {
|
||||
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||
});
|
||||
it("returns undefined on invalid JSON", () => {
|
||||
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
||||
const sample = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "hello from result field",
|
||||
duration_ms: 1234,
|
||||
usage: { server_tool_use: { tool_a: 2 } },
|
||||
};
|
||||
const parsed = parseClaudeJson(JSON.stringify(sample));
|
||||
expect(parsed?.text).toBe("hello from result field");
|
||||
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
||||
expect(parsed?.valid).toBe(true);
|
||||
});
|
||||
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
||||
const sample = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "hello from result field",
|
||||
duration_ms: 1234,
|
||||
usage: { server_tool_use: { tool_a: 2 } },
|
||||
};
|
||||
const parsed = parseClaudeJson(JSON.stringify(sample));
|
||||
expect(parsed?.text).toBe("hello from result field");
|
||||
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
||||
expect(parsed?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||
expect(parsed?.valid).toBe(false);
|
||||
expect(parsed?.text).toBeUndefined();
|
||||
});
|
||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||
expect(parsed?.valid).toBe(false);
|
||||
expect(parsed?.text).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+139
-139
@@ -4,159 +4,159 @@ import { z } from "zod";
|
||||
// Preferred binary name for Claude CLI invocations.
|
||||
export const CLAUDE_BIN = "claude";
|
||||
export const CLAUDE_IDENTITY_PREFIX =
|
||||
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters.";
|
||||
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
|
||||
|
||||
function extractClaudeText(payload: unknown): string | undefined {
|
||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||
if (payload == null) return undefined;
|
||||
if (typeof payload === "string") return payload;
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = extractClaudeText(item);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof payload === "object") {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
if (typeof obj.result === "string") return obj.result;
|
||||
if (typeof obj.text === "string") return obj.text;
|
||||
if (typeof obj.completion === "string") return obj.completion;
|
||||
if (typeof obj.output === "string") return obj.output;
|
||||
if (obj.message) {
|
||||
const inner = extractClaudeText(obj.message);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.messages)) {
|
||||
const inner = extractClaudeText(obj.messages);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
for (const block of obj.content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: string }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return (block as { text: string }).text;
|
||||
}
|
||||
const inner = extractClaudeText(block);
|
||||
if (inner) return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||
if (payload == null) return undefined;
|
||||
if (typeof payload === "string") return payload;
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const found = extractClaudeText(item);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof payload === "object") {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
if (typeof obj.result === "string") return obj.result;
|
||||
if (typeof obj.text === "string") return obj.text;
|
||||
if (typeof obj.completion === "string") return obj.completion;
|
||||
if (typeof obj.output === "string") return obj.output;
|
||||
if (obj.message) {
|
||||
const inner = extractClaudeText(obj.message);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.messages)) {
|
||||
const inner = extractClaudeText(obj.messages);
|
||||
if (inner) return inner;
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
for (const block of obj.content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as { type?: string }).type === "text" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return (block as { text: string }).text;
|
||||
}
|
||||
const inner = extractClaudeText(block);
|
||||
if (inner) return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type ClaudeJsonParseResult = {
|
||||
text?: string;
|
||||
parsed: unknown;
|
||||
valid: boolean;
|
||||
text?: string;
|
||||
parsed: unknown;
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
const ClaudeJsonSchema = z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
subtype: z.string().optional(),
|
||||
is_error: z.boolean().optional(),
|
||||
result: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
completion: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
message: z.any().optional(),
|
||||
messages: z.any().optional(),
|
||||
content: z.any().optional(),
|
||||
duration_ms: z.number().optional(),
|
||||
duration_api_ms: z.number().optional(),
|
||||
num_turns: z.number().optional(),
|
||||
session_id: z.string().optional(),
|
||||
total_cost_usd: z.number().optional(),
|
||||
usage: z.record(z.string(), z.any()).optional(),
|
||||
modelUsage: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.refine(
|
||||
(obj) =>
|
||||
typeof obj.result === "string" ||
|
||||
typeof obj.text === "string" ||
|
||||
typeof obj.completion === "string" ||
|
||||
typeof obj.output === "string" ||
|
||||
obj.message !== undefined ||
|
||||
obj.messages !== undefined ||
|
||||
obj.content !== undefined,
|
||||
{ message: "Not a Claude JSON payload" },
|
||||
);
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
subtype: z.string().optional(),
|
||||
is_error: z.boolean().optional(),
|
||||
result: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
completion: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
message: z.any().optional(),
|
||||
messages: z.any().optional(),
|
||||
content: z.any().optional(),
|
||||
duration_ms: z.number().optional(),
|
||||
duration_api_ms: z.number().optional(),
|
||||
num_turns: z.number().optional(),
|
||||
session_id: z.string().optional(),
|
||||
total_cost_usd: z.number().optional(),
|
||||
usage: z.record(z.string(), z.any()).optional(),
|
||||
modelUsage: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.refine(
|
||||
(obj) =>
|
||||
typeof obj.result === "string" ||
|
||||
typeof obj.text === "string" ||
|
||||
typeof obj.completion === "string" ||
|
||||
typeof obj.output === "string" ||
|
||||
obj.message !== undefined ||
|
||||
obj.messages !== undefined ||
|
||||
obj.content !== undefined,
|
||||
{ message: "Not a Claude JSON payload" },
|
||||
);
|
||||
|
||||
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
||||
|
||||
export function parseClaudeJson(
|
||||
raw: string,
|
||||
raw: string,
|
||||
): ClaudeJsonParseResult | undefined {
|
||||
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
||||
let firstParsed: unknown;
|
||||
const candidates = [
|
||||
raw,
|
||||
...raw
|
||||
.split(/\n+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (firstParsed === undefined) firstParsed = parsed;
|
||||
let validation: ClaudeSafeParse | { success: false };
|
||||
try {
|
||||
validation = ClaudeJsonSchema.safeParse(parsed);
|
||||
} catch {
|
||||
validation = { success: false } as const;
|
||||
}
|
||||
const validated = validation.success ? validation.data : parsed;
|
||||
const isLikelyClaude =
|
||||
typeof validated === "object" &&
|
||||
validated !== null &&
|
||||
("result" in validated ||
|
||||
"text" in validated ||
|
||||
"completion" in validated ||
|
||||
"output" in validated);
|
||||
const text = extractClaudeText(validated);
|
||||
if (text)
|
||||
return {
|
||||
parsed: validated,
|
||||
text,
|
||||
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
||||
valid: Boolean(validation?.success || isLikelyClaude),
|
||||
};
|
||||
} catch {
|
||||
// ignore parse errors; try next candidate
|
||||
}
|
||||
}
|
||||
if (firstParsed !== undefined) {
|
||||
let validation: ClaudeSafeParse | { success: false };
|
||||
try {
|
||||
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
||||
} catch {
|
||||
validation = { success: false } as const;
|
||||
}
|
||||
const validated = validation.success ? validation.data : firstParsed;
|
||||
const isLikelyClaude =
|
||||
typeof validated === "object" &&
|
||||
validated !== null &&
|
||||
("result" in validated ||
|
||||
"text" in validated ||
|
||||
"completion" in validated ||
|
||||
"output" in validated);
|
||||
return {
|
||||
parsed: validated,
|
||||
text: extractClaudeText(validated),
|
||||
valid: Boolean(validation?.success || isLikelyClaude),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
||||
let firstParsed: unknown;
|
||||
const candidates = [
|
||||
raw,
|
||||
...raw
|
||||
.split(/\n+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (firstParsed === undefined) firstParsed = parsed;
|
||||
let validation: ClaudeSafeParse | { success: false };
|
||||
try {
|
||||
validation = ClaudeJsonSchema.safeParse(parsed);
|
||||
} catch {
|
||||
validation = { success: false } as const;
|
||||
}
|
||||
const validated = validation.success ? validation.data : parsed;
|
||||
const isLikelyClaude =
|
||||
typeof validated === "object" &&
|
||||
validated !== null &&
|
||||
("result" in validated ||
|
||||
"text" in validated ||
|
||||
"completion" in validated ||
|
||||
"output" in validated);
|
||||
const text = extractClaudeText(validated);
|
||||
if (text)
|
||||
return {
|
||||
parsed: validated,
|
||||
text,
|
||||
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
||||
valid: Boolean(validation?.success || isLikelyClaude),
|
||||
};
|
||||
} catch {
|
||||
// ignore parse errors; try next candidate
|
||||
}
|
||||
}
|
||||
if (firstParsed !== undefined) {
|
||||
let validation: ClaudeSafeParse | { success: false };
|
||||
try {
|
||||
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
||||
} catch {
|
||||
validation = { success: false } as const;
|
||||
}
|
||||
const validated = validation.success ? validation.data : firstParsed;
|
||||
const isLikelyClaude =
|
||||
typeof validated === "object" &&
|
||||
validated !== null &&
|
||||
("result" in validated ||
|
||||
"text" in validated ||
|
||||
"completion" in validated ||
|
||||
"output" in validated);
|
||||
return {
|
||||
parsed: validated,
|
||||
text: extractClaudeText(validated),
|
||||
valid: Boolean(validation?.success || isLikelyClaude),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseClaudeJsonText(raw: string): string | undefined {
|
||||
const parsed = parseClaudeJson(raw);
|
||||
return parsed?.text;
|
||||
const parsed = parseClaudeJson(raw);
|
||||
return parsed?.text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runCommandReply, summarizeClaudeMetadata } from "./command-reply.js";
|
||||
import type { ReplyPayload } from "./types.js";
|
||||
|
||||
const noopTemplateCtx = {
|
||||
Body: "hello",
|
||||
BodyStripped: "hello",
|
||||
SessionId: "sess",
|
||||
IsNewSession: "true",
|
||||
};
|
||||
|
||||
type RunnerResult = {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
signal?: string | null;
|
||||
killed?: boolean;
|
||||
};
|
||||
|
||||
function makeRunner(result: RunnerResult, capture: ReplyPayload[] = []) {
|
||||
return vi.fn(async (argv: string[]) => {
|
||||
capture.push({ text: argv.join(" "), argv });
|
||||
return {
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? "",
|
||||
code: result.code ?? 0,
|
||||
signal: result.signal ?? null,
|
||||
killed: result.killed ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const enqueueImmediate = vi.fn(
|
||||
async <T>(
|
||||
task: () => Promise<T>,
|
||||
opts?: { onWait?: (ms: number, ahead: number) => void },
|
||||
) => {
|
||||
opts?.onWait?.(25, 2);
|
||||
return task();
|
||||
},
|
||||
);
|
||||
|
||||
describe("summarizeClaudeMetadata", () => {
|
||||
it("builds concise meta string", () => {
|
||||
const meta = summarizeClaudeMetadata({
|
||||
duration_ms: 1200,
|
||||
num_turns: 3,
|
||||
total_cost_usd: 0.012345,
|
||||
usage: { server_tool_use: { a: 1, b: 2 } },
|
||||
modelUsage: { "claude-3": 2, haiku: 1 },
|
||||
});
|
||||
expect(meta).toContain("duration=1200ms");
|
||||
expect(meta).toContain("turns=3");
|
||||
expect(meta).toContain("cost=$0.0123");
|
||||
expect(meta).toContain("tool_calls=3");
|
||||
expect(meta).toContain("models=claude-3,haiku");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCommandReply", () => {
|
||||
it("injects claude flags and identity prefix", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||
const { payload } = await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
|
||||
expect(payload?.text).toBe("ok");
|
||||
const finalArgv = captures[0].argv as string[];
|
||||
expect(finalArgv).toContain("--output-format");
|
||||
expect(finalArgv).toContain("json");
|
||||
expect(finalArgv).toContain("-p");
|
||||
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
|
||||
});
|
||||
|
||||
it("omits identity prefix on resumed session when sendSystemOnce=true", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||
await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
isNewSession: false,
|
||||
isFirstTurnInSession: false,
|
||||
systemSent: true,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
const finalArgv = captures[0].argv as string[];
|
||||
expect(finalArgv.at(-1)).not.toContain("You are Clawd (Claude)");
|
||||
});
|
||||
|
||||
it("prepends identity on first turn when sendSystemOnce=true", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||
await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
const finalArgv = captures[0].argv as string[];
|
||||
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
|
||||
});
|
||||
|
||||
it("still prepends identity if resume session but systemSent=false", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "ok" }, captures);
|
||||
await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: true,
|
||||
isNewSession: false,
|
||||
isFirstTurnInSession: false,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
const finalArgv = captures[0].argv as string[];
|
||||
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
|
||||
});
|
||||
|
||||
it("picks session resume args when not new", async () => {
|
||||
const captures: ReplyPayload[] = [];
|
||||
const runner = makeRunner({ stdout: "hi" }, captures);
|
||||
await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["cli", "{{Body}}"],
|
||||
session: {
|
||||
sessionArgNew: ["--new", "{{SessionId}}"],
|
||||
sessionArgResume: ["--resume", "{{SessionId}}"],
|
||||
},
|
||||
},
|
||||
templatingCtx: { ...noopTemplateCtx, SessionId: "abc" },
|
||||
sendSystemOnce: true,
|
||||
isNewSession: false,
|
||||
isFirstTurnInSession: false,
|
||||
systemSent: true,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
const argv = captures[0].argv as string[];
|
||||
expect(argv).toContain("--resume");
|
||||
expect(argv).toContain("abc");
|
||||
});
|
||||
|
||||
it("returns timeout text with partial snippet", async () => {
|
||||
const runner = vi.fn(async () => {
|
||||
throw { stdout: "partial output here", killed: true, signal: "SIGKILL" };
|
||||
});
|
||||
const { payload, meta } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"] },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 10,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(payload?.text).toContain("Command timed out after 1s");
|
||||
expect(payload?.text).toContain("partial output");
|
||||
expect(meta.killed).toBe(true);
|
||||
});
|
||||
|
||||
it("includes cwd hint in timeout message", async () => {
|
||||
const runner = vi.fn(async () => {
|
||||
throw { stdout: "", killed: true, signal: "SIGKILL" };
|
||||
});
|
||||
const { payload } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"], cwd: "/tmp/work" },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 5,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(payload?.text).toContain("(cwd: /tmp/work)");
|
||||
});
|
||||
|
||||
it("parses MEDIA tokens and respects mediaMaxMb for local files", async () => {
|
||||
const tmp = path.join(os.tmpdir(), `warelay-test-${Date.now()}.bin`);
|
||||
const bigBuffer = Buffer.alloc(2 * 1024 * 1024, 1);
|
||||
await fs.writeFile(tmp, bigBuffer);
|
||||
const runner = makeRunner({
|
||||
stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`,
|
||||
});
|
||||
const { payload } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "hi"], mediaMaxMb: 1 },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(payload?.mediaUrls).toEqual(["https://example.com/img.jpg"]);
|
||||
await fs.unlink(tmp);
|
||||
});
|
||||
|
||||
it("emits Claude metadata", async () => {
|
||||
const runner = makeRunner({
|
||||
stdout:
|
||||
'{"text":"hi","duration_ms":50,"total_cost_usd":0.0001,"usage":{"server_tool_use":{"a":1}}}',
|
||||
});
|
||||
const { meta } = await runCommandReply({
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["claude", "{{Body}}"],
|
||||
claudeOutputFormat: "json",
|
||||
},
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 1000,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(meta.claudeMeta).toContain("duration=50ms");
|
||||
expect(meta.claudeMeta).toContain("tool_calls=1");
|
||||
});
|
||||
|
||||
it("captures queue wait metrics in meta", async () => {
|
||||
const runner = makeRunner({ stdout: "ok" });
|
||||
const { meta } = await runCommandReply({
|
||||
reply: { mode: "command", command: ["echo", "{{Body}}"] },
|
||||
templatingCtx: noopTemplateCtx,
|
||||
sendSystemOnce: false,
|
||||
isNewSession: true,
|
||||
isFirstTurnInSession: true,
|
||||
systemSent: false,
|
||||
timeoutMs: 100,
|
||||
timeoutSeconds: 1,
|
||||
commandRunner: runner,
|
||||
enqueue: enqueueImmediate,
|
||||
});
|
||||
expect(meta.queuedMs).toBe(25);
|
||||
expect(meta.queuedAhead).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { WarelayConfig } from "../config/config.js";
|
||||
import { isVerbose, logVerbose } from "../globals.js";
|
||||
import { logError } from "../logger.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { enqueueCommand } from "../process/command-queue.js";
|
||||
import type { runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
CLAUDE_BIN,
|
||||
CLAUDE_IDENTITY_PREFIX,
|
||||
type ClaudeJsonParseResult,
|
||||
parseClaudeJson,
|
||||
} from "./claude.js";
|
||||
import { applyTemplate, type TemplateContext } from "./templating.js";
|
||||
import type { ReplyPayload } from "./types.js";
|
||||
|
||||
type CommandReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"] & {
|
||||
mode: "command";
|
||||
};
|
||||
|
||||
type EnqueueRunner = typeof enqueueCommand;
|
||||
|
||||
type CommandReplyParams = {
|
||||
reply: CommandReplyConfig;
|
||||
templatingCtx: TemplateContext;
|
||||
sendSystemOnce: boolean;
|
||||
isNewSession: boolean;
|
||||
isFirstTurnInSession: boolean;
|
||||
systemSent: boolean;
|
||||
timeoutMs: number;
|
||||
timeoutSeconds: number;
|
||||
commandRunner: typeof runCommandWithTimeout;
|
||||
enqueue?: EnqueueRunner;
|
||||
};
|
||||
|
||||
export type CommandReplyMeta = {
|
||||
durationMs: number;
|
||||
queuedMs?: number;
|
||||
queuedAhead?: number;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
killed?: boolean;
|
||||
claudeMeta?: string;
|
||||
};
|
||||
|
||||
export type CommandReplyResult = {
|
||||
payload?: ReplyPayload;
|
||||
meta: CommandReplyMeta;
|
||||
};
|
||||
|
||||
export function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (typeof obj.duration_ms === "number") {
|
||||
parts.push(`duration=${obj.duration_ms}ms`);
|
||||
}
|
||||
if (typeof obj.duration_api_ms === "number") {
|
||||
parts.push(`api=${obj.duration_api_ms}ms`);
|
||||
}
|
||||
if (typeof obj.num_turns === "number") {
|
||||
parts.push(`turns=${obj.num_turns}`);
|
||||
}
|
||||
if (typeof obj.total_cost_usd === "number") {
|
||||
parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`);
|
||||
}
|
||||
|
||||
const usage = obj.usage;
|
||||
if (usage && typeof usage === "object") {
|
||||
const serverToolUse = (
|
||||
usage as { server_tool_use?: Record<string, unknown> }
|
||||
).server_tool_use;
|
||||
if (serverToolUse && typeof serverToolUse === "object") {
|
||||
const toolCalls = Object.values(serverToolUse).reduce<number>(
|
||||
(sum, val) => {
|
||||
if (typeof val === "number") return sum + val;
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
|
||||
}
|
||||
}
|
||||
|
||||
const modelUsage = obj.modelUsage;
|
||||
if (modelUsage && typeof modelUsage === "object") {
|
||||
const models = Object.keys(modelUsage as Record<string, unknown>);
|
||||
if (models.length) {
|
||||
const display =
|
||||
models.length > 2
|
||||
? `${models.slice(0, 2).join(",")}+${models.length - 2}`
|
||||
: models.join(",");
|
||||
parts.push(`models=${display}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(", ") : undefined;
|
||||
}
|
||||
|
||||
export async function runCommandReply(
|
||||
params: CommandReplyParams,
|
||||
): Promise<CommandReplyResult> {
|
||||
const {
|
||||
reply,
|
||||
templatingCtx,
|
||||
sendSystemOnce,
|
||||
isNewSession,
|
||||
isFirstTurnInSession,
|
||||
systemSent,
|
||||
timeoutMs,
|
||||
timeoutSeconds,
|
||||
commandRunner,
|
||||
enqueue = enqueueCommand,
|
||||
} = params;
|
||||
|
||||
if (!reply.command?.length) {
|
||||
throw new Error("reply.command is required for mode=command");
|
||||
}
|
||||
|
||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||
const templatePrefix =
|
||||
reply.template && (!sendSystemOnce || isFirstTurnInSession || !systemSent)
|
||||
? applyTemplate(reply.template, templatingCtx)
|
||||
: "";
|
||||
if (templatePrefix && argv.length > 0) {
|
||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
||||
}
|
||||
|
||||
// Ensure Claude commands can emit plain text by forcing --output-format when configured.
|
||||
if (
|
||||
reply.claudeOutputFormat &&
|
||||
argv.length > 0 &&
|
||||
path.basename(argv[0]) === CLAUDE_BIN
|
||||
) {
|
||||
const hasOutputFormat = argv.some(
|
||||
(part) =>
|
||||
part === "--output-format" || part.startsWith("--output-format="),
|
||||
);
|
||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
||||
if (!hasOutputFormat) {
|
||||
argv = [
|
||||
...argv.slice(0, insertBeforeBody),
|
||||
"--output-format",
|
||||
reply.claudeOutputFormat,
|
||||
...argv.slice(insertBeforeBody),
|
||||
];
|
||||
}
|
||||
const hasPrintFlag = argv.some(
|
||||
(part) => part === "-p" || part === "--print",
|
||||
);
|
||||
if (!hasPrintFlag) {
|
||||
const insertIdx = Math.max(argv.length - 1, 0);
|
||||
argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)];
|
||||
}
|
||||
}
|
||||
|
||||
// Inject session args if configured (use resume for existing, session-id for new)
|
||||
if (reply.session) {
|
||||
const sessionArgList = (
|
||||
isNewSession
|
||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
||||
).map((part) => applyTemplate(part, templatingCtx));
|
||||
if (sessionArgList.length) {
|
||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||
const insertAt =
|
||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||
argv = [
|
||||
...argv.slice(0, insertAt),
|
||||
...sessionArgList,
|
||||
...argv.slice(insertAt),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
let finalArgv = argv;
|
||||
const isClaudeInvocation =
|
||||
finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN;
|
||||
const shouldPrependIdentity =
|
||||
isClaudeInvocation && !(sendSystemOnce && systemSent);
|
||||
if (shouldPrependIdentity && finalArgv.length > 0) {
|
||||
const bodyIdx = finalArgv.length - 1;
|
||||
const existingBody = finalArgv[bodyIdx] ?? "";
|
||||
finalArgv = [
|
||||
...finalArgv.slice(0, bodyIdx),
|
||||
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
|
||||
];
|
||||
}
|
||||
logVerbose(
|
||||
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
||||
);
|
||||
|
||||
const started = Date.now();
|
||||
let queuedMs: number | undefined;
|
||||
let queuedAhead: number | undefined;
|
||||
try {
|
||||
const { stdout, stderr, code, signal, killed } = await enqueue(
|
||||
() => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }),
|
||||
{
|
||||
onWait: (waitMs, ahead) => {
|
||||
queuedMs = waitMs;
|
||||
queuedAhead = ahead;
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
const rawStdout = stdout.trim();
|
||||
let mediaFromCommand: string[] | undefined;
|
||||
let trimmed = rawStdout;
|
||||
if (stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
||||
}
|
||||
let parsed: ClaudeJsonParseResult | undefined;
|
||||
if (
|
||||
trimmed &&
|
||||
(reply.claudeOutputFormat === "json" || isClaudeInvocation)
|
||||
) {
|
||||
parsed = parseClaudeJson(trimmed);
|
||||
if (parsed?.parsed && isVerbose()) {
|
||||
const summary = summarizeClaudeMetadata(parsed.parsed);
|
||||
if (summary) logVerbose(`Claude JSON meta: ${summary}`);
|
||||
logVerbose(
|
||||
`Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`,
|
||||
);
|
||||
}
|
||||
if (parsed?.text) {
|
||||
logVerbose(
|
||||
`Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`,
|
||||
);
|
||||
trimmed = parsed.text.trim();
|
||||
} else {
|
||||
logVerbose("Claude JSON parse failed; returning raw stdout");
|
||||
}
|
||||
}
|
||||
const { text: cleanedText, mediaUrls: mediaFound } =
|
||||
splitMediaFromOutput(trimmed);
|
||||
trimmed = cleanedText;
|
||||
if (mediaFound?.length) {
|
||||
mediaFromCommand = mediaFound;
|
||||
if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`);
|
||||
} else if (isVerbose()) {
|
||||
logVerbose("No MEDIA token extracted from final text");
|
||||
}
|
||||
if (!trimmed && !mediaFromCommand) {
|
||||
const meta = parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined;
|
||||
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
|
||||
logVerbose("No text/media produced; injecting fallback notice to user");
|
||||
}
|
||||
logVerbose(`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`);
|
||||
logVerbose(`Command auto-reply finished in ${Date.now() - started}ms`);
|
||||
if ((code ?? 0) !== 0) {
|
||||
console.error(
|
||||
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
||||
);
|
||||
return {
|
||||
payload: undefined,
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed
|
||||
? summarizeClaudeMetadata(parsed.parsed)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (killed && !signal) {
|
||||
console.error(
|
||||
`Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`,
|
||||
);
|
||||
return {
|
||||
payload: undefined,
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed
|
||||
? summarizeClaudeMetadata(parsed.parsed)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
let mediaUrls =
|
||||
mediaFromCommand ?? (reply.mediaUrl ? [reply.mediaUrl] : undefined);
|
||||
|
||||
// If mediaMaxMb is set, skip local media paths larger than the cap.
|
||||
if (mediaUrls?.length && reply.mediaMaxMb) {
|
||||
const maxBytes = reply.mediaMaxMb * 1024 * 1024;
|
||||
const filtered: string[] = [];
|
||||
for (const url of mediaUrls) {
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
filtered.push(url);
|
||||
continue;
|
||||
}
|
||||
const abs = path.isAbsolute(url) ? url : path.resolve(url);
|
||||
try {
|
||||
const stats = await fs.stat(abs);
|
||||
if (stats.size <= maxBytes) {
|
||||
filtered.push(url);
|
||||
} else if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Skipping media ${url} (${(stats.size / (1024 * 1024)).toFixed(2)}MB) over cap ${reply.mediaMaxMb}MB`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
filtered.push(url);
|
||||
}
|
||||
}
|
||||
mediaUrls = filtered;
|
||||
}
|
||||
|
||||
const payload =
|
||||
trimmed || mediaUrls?.length
|
||||
? {
|
||||
text: trimmed || undefined,
|
||||
mediaUrl: mediaUrls?.[0],
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined;
|
||||
const meta: CommandReplyMeta = {
|
||||
durationMs: Date.now() - started,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: code,
|
||||
signal,
|
||||
killed,
|
||||
claudeMeta: parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined,
|
||||
};
|
||||
if (isVerbose()) {
|
||||
logVerbose(`Command auto-reply meta: ${JSON.stringify(meta)}`);
|
||||
}
|
||||
return { payload, meta };
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - started;
|
||||
const anyErr = err as { killed?: boolean; signal?: string };
|
||||
const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL";
|
||||
const errorObj = err as { stdout?: string; stderr?: string };
|
||||
if (errorObj.stderr?.trim()) {
|
||||
logVerbose(`Command auto-reply stderr: ${errorObj.stderr.trim()}`);
|
||||
}
|
||||
if (timeoutHit) {
|
||||
console.error(
|
||||
`Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`,
|
||||
);
|
||||
const baseMsg =
|
||||
"Command timed out after " +
|
||||
`${timeoutSeconds}s${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}. Try a shorter prompt or split the request.`;
|
||||
const partial = errorObj.stdout?.trim();
|
||||
const partialSnippet =
|
||||
partial && partial.length > 800
|
||||
? `${partial.slice(0, 800)}...`
|
||||
: partial;
|
||||
const text = partialSnippet
|
||||
? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}`
|
||||
: baseMsg;
|
||||
return {
|
||||
payload: { text },
|
||||
meta: {
|
||||
durationMs: elapsed,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: undefined,
|
||||
signal: anyErr.signal,
|
||||
killed: anyErr.killed,
|
||||
},
|
||||
};
|
||||
}
|
||||
logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`);
|
||||
return {
|
||||
payload: undefined,
|
||||
meta: {
|
||||
durationMs: elapsed,
|
||||
queuedMs,
|
||||
queuedAhead,
|
||||
exitCode: undefined,
|
||||
signal: anyErr.signal,
|
||||
killed: anyErr.killed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+371
-451
@@ -1,495 +1,415 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
DEFAULT_RESET_TRIGGER,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
DEFAULT_RESET_TRIGGER,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { info, isVerbose, logVerbose } from "../globals.js";
|
||||
import { logError } from "../logger.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { enqueueCommand } from "../process/command-queue.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import type { TwilioRequester } from "../twilio/types.js";
|
||||
import { sendTypingIndicator } from "../twilio/typing.js";
|
||||
import { runCommandReply } from "./command-reply.js";
|
||||
import {
|
||||
CLAUDE_BIN,
|
||||
CLAUDE_IDENTITY_PREFIX,
|
||||
type ClaudeJsonParseResult,
|
||||
parseClaudeJson,
|
||||
} from "./claude.js";
|
||||
import {
|
||||
applyTemplate,
|
||||
type MsgContext,
|
||||
type TemplateContext,
|
||||
applyTemplate,
|
||||
type MsgContext,
|
||||
type TemplateContext,
|
||||
} from "./templating.js";
|
||||
import { isAudio, transcribeInboundAudio } from "./transcription.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||
|
||||
type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (typeof obj.duration_ms === "number") {
|
||||
parts.push(`duration=${obj.duration_ms}ms`);
|
||||
}
|
||||
if (typeof obj.duration_api_ms === "number") {
|
||||
parts.push(`api=${obj.duration_api_ms}ms`);
|
||||
}
|
||||
if (typeof obj.num_turns === "number") {
|
||||
parts.push(`turns=${obj.num_turns}`);
|
||||
}
|
||||
if (typeof obj.total_cost_usd === "number") {
|
||||
parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`);
|
||||
}
|
||||
|
||||
const usage = obj.usage;
|
||||
if (usage && typeof usage === "object") {
|
||||
const serverToolUse = (
|
||||
usage as { server_tool_use?: Record<string, unknown> }
|
||||
).server_tool_use;
|
||||
if (serverToolUse && typeof serverToolUse === "object") {
|
||||
const toolCalls = Object.values(serverToolUse).reduce<number>(
|
||||
(sum, val) => {
|
||||
if (typeof val === "number") return sum + val;
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
|
||||
}
|
||||
}
|
||||
|
||||
const modelUsage = obj.modelUsage;
|
||||
if (modelUsage && typeof modelUsage === "object") {
|
||||
const models = Object.keys(modelUsage as Record<string, unknown>);
|
||||
if (models.length) {
|
||||
const display =
|
||||
models.length > 2
|
||||
? `${models.slice(0, 2).join(",")}+${models.length - 2}`
|
||||
: models.join(",");
|
||||
parts.push(`models=${display}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(", ") : undefined;
|
||||
}
|
||||
|
||||
export type ReplyPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
};
|
||||
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||
|
||||
export async function getReplyFromConfig(
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: WarelayConfig,
|
||||
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: WarelayConfig,
|
||||
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
||||
): Promise<ReplyPayload | undefined> {
|
||||
// Choose reply from config: static text or external command stdout.
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const reply = cfg.inbound?.reply;
|
||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let started = false;
|
||||
const onReplyStart = async () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
await opts?.onReplyStart?.();
|
||||
};
|
||||
// Choose reply from config: static text or external command stdout.
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const reply = cfg.inbound?.reply;
|
||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let started = false;
|
||||
const triggerTyping = async () => {
|
||||
await opts?.onReplyStart?.();
|
||||
};
|
||||
const onReplyStart = async () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
await triggerTyping();
|
||||
};
|
||||
let typingTimer: NodeJS.Timeout | undefined;
|
||||
const typingIntervalMs =
|
||||
reply?.mode === "command"
|
||||
? (reply.typingIntervalSeconds ??
|
||||
reply?.session?.typingIntervalSeconds ??
|
||||
8) * 1000
|
||||
: 0;
|
||||
const cleanupTyping = () => {
|
||||
if (typingTimer) {
|
||||
clearInterval(typingTimer);
|
||||
typingTimer = undefined;
|
||||
}
|
||||
};
|
||||
const startTypingLoop = async () => {
|
||||
if (!opts?.onReplyStart) return;
|
||||
if (typingIntervalMs <= 0) return;
|
||||
if (typingTimer) return;
|
||||
await triggerTyping();
|
||||
typingTimer = setInterval(() => {
|
||||
void triggerTyping();
|
||||
}, typingIntervalMs);
|
||||
};
|
||||
let transcribedText: string | undefined;
|
||||
|
||||
// Optional session handling (conversation reuse + /new resets)
|
||||
const sessionCfg = reply?.session;
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: [DEFAULT_RESET_TRIGGER];
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
// Optional audio transcription before templating/session handling.
|
||||
if (cfg.inbound?.transcribeAudio && isAudio(ctx.MediaType)) {
|
||||
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
|
||||
if (transcribed?.text) {
|
||||
transcribedText = transcribed.text;
|
||||
ctx.Body = transcribed.text;
|
||||
ctx.Transcript = transcribed.text;
|
||||
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||
}
|
||||
}
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let isNewSession = false;
|
||||
let bodyStripped: string | undefined;
|
||||
// Optional session handling (conversation reuse + /new resets)
|
||||
const sessionCfg = reply?.session;
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: [DEFAULT_RESET_TRIGGER];
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
let sessionStore: ReturnType<typeof loadSessionStore> | undefined;
|
||||
let sessionKey: string | undefined;
|
||||
|
||||
if (sessionCfg) {
|
||||
const trimmedBody = (ctx.Body ?? "").trim();
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
if (trimmedBody === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
break;
|
||||
}
|
||||
const triggerPrefix = `${trigger} `;
|
||||
if (trimmedBody.startsWith(triggerPrefix)) {
|
||||
isNewSession = true;
|
||||
bodyStripped = trimmedBody.slice(trigger.length).trimStart();
|
||||
break;
|
||||
}
|
||||
}
|
||||
let sessionId: string | undefined;
|
||||
let isNewSession = false;
|
||||
let bodyStripped: string | undefined;
|
||||
let systemSent = false;
|
||||
|
||||
const sessionKey = deriveSessionKey(sessionScope, ctx);
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
if (sessionCfg) {
|
||||
const trimmedBody = (ctx.Body ?? "").trim();
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
if (trimmedBody === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
break;
|
||||
}
|
||||
const triggerPrefix = `${trigger} `;
|
||||
if (trimmedBody.startsWith(triggerPrefix)) {
|
||||
isNewSession = true;
|
||||
bodyStripped = trimmedBody.slice(trigger.length).trimStart();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNewSession && freshEntry) {
|
||||
sessionId = entry.sessionId;
|
||||
} else {
|
||||
sessionId = crypto.randomUUID();
|
||||
isNewSession = true;
|
||||
}
|
||||
sessionKey = deriveSessionKey(sessionScope, ctx);
|
||||
sessionStore = loadSessionStore(storePath);
|
||||
const entry = sessionStore[sessionKey];
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
|
||||
store[sessionKey] = { sessionId, updatedAt: Date.now() };
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
if (!isNewSession && freshEntry) {
|
||||
sessionId = entry.sessionId;
|
||||
systemSent = entry.systemSent ?? false;
|
||||
} else {
|
||||
sessionId = crypto.randomUUID();
|
||||
isNewSession = true;
|
||||
systemSent = false;
|
||||
}
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
BodyStripped: bodyStripped ?? ctx.Body,
|
||||
SessionId: sessionId,
|
||||
IsNewSession: isNewSession ? "true" : "false",
|
||||
};
|
||||
sessionStore[sessionKey] = { sessionId, updatedAt: Date.now(), systemSent };
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
|
||||
// Optional prefix injected before Body for templating/command prompts.
|
||||
const bodyPrefix = reply?.bodyPrefix
|
||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
||||
: "";
|
||||
const prefixedBody = bodyPrefix
|
||||
? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}`
|
||||
: (sessionCtx.BodyStripped ?? sessionCtx.Body);
|
||||
const mediaNote = ctx.MediaPath?.length
|
||||
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
|
||||
: undefined;
|
||||
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
|
||||
const mediaReplyHint =
|
||||
mediaNote && reply?.mode === "command"
|
||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||
: undefined;
|
||||
const commandBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
: prefixedBody;
|
||||
const templatingCtx: TemplateContext = {
|
||||
...sessionCtx,
|
||||
Body: commandBody,
|
||||
BodyStripped: commandBody,
|
||||
};
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
BodyStripped: bodyStripped ?? ctx.Body,
|
||||
SessionId: sessionId,
|
||||
IsNewSession: isNewSession ? "true" : "false",
|
||||
};
|
||||
|
||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||
const allowFrom = cfg.inbound?.allowFrom;
|
||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
if (!allowFrom.includes(from)) {
|
||||
logVerbose(
|
||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (!reply) {
|
||||
logVerbose("No inbound.reply configured; skipping auto-reply");
|
||||
return undefined;
|
||||
}
|
||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||
const allowFrom = cfg.inbound?.allowFrom;
|
||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
// Support "*" as wildcard to allow all senders
|
||||
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
||||
logVerbose(
|
||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
||||
);
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (reply.mode === "text" && reply.text) {
|
||||
await onReplyStart();
|
||||
logVerbose("Using text auto-reply from config");
|
||||
return {
|
||||
text: applyTemplate(reply.text, templatingCtx),
|
||||
mediaUrl: reply.mediaUrl,
|
||||
};
|
||||
}
|
||||
await startTypingLoop();
|
||||
|
||||
if (reply.mode === "command" && reply.command?.length) {
|
||||
await onReplyStart();
|
||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
||||
const templatePrefix = reply.template
|
||||
? applyTemplate(reply.template, templatingCtx)
|
||||
: "";
|
||||
if (templatePrefix && argv.length > 0) {
|
||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
||||
}
|
||||
// Optional prefix injected before Body for templating/command prompts.
|
||||
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
|
||||
const isFirstTurnInSession = isNewSession || !systemSent;
|
||||
const sessionIntro =
|
||||
isFirstTurnInSession && sessionCfg?.sessionIntro
|
||||
? applyTemplate(sessionCfg.sessionIntro, sessionCtx)
|
||||
: "";
|
||||
const bodyPrefix = reply?.bodyPrefix
|
||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
||||
: "";
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const prefixedBodyBase = (() => {
|
||||
let body = baseBody;
|
||||
if (!sendSystemOnce || isFirstTurnInSession) {
|
||||
body = bodyPrefix ? `${bodyPrefix}${body}` : body;
|
||||
}
|
||||
if (sessionIntro) {
|
||||
body = `${sessionIntro}\n\n${body}`;
|
||||
}
|
||||
return body;
|
||||
})();
|
||||
if (
|
||||
sessionCfg &&
|
||||
sendSystemOnce &&
|
||||
isFirstTurnInSession &&
|
||||
sessionStore &&
|
||||
sessionKey
|
||||
) {
|
||||
sessionStore[sessionKey] = {
|
||||
...(sessionStore[sessionKey] ?? {}),
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
};
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
systemSent = true;
|
||||
}
|
||||
|
||||
// Ensure Claude commands can emit plain text by forcing --output-format when configured.
|
||||
// We inject the flags only when the user points at the `claude` binary and has opted in via config,
|
||||
// so existing custom argv or non-Claude commands remain untouched.
|
||||
if (
|
||||
reply.claudeOutputFormat &&
|
||||
argv.length > 0 &&
|
||||
path.basename(argv[0]) === CLAUDE_BIN
|
||||
) {
|
||||
const hasOutputFormat = argv.some(
|
||||
(part) =>
|
||||
part === "--output-format" || part.startsWith("--output-format="),
|
||||
);
|
||||
// Keep the final argument as the prompt/body; insert options just before it.
|
||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
||||
if (!hasOutputFormat) {
|
||||
argv = [
|
||||
...argv.slice(0, insertBeforeBody),
|
||||
"--output-format",
|
||||
reply.claudeOutputFormat,
|
||||
...argv.slice(insertBeforeBody),
|
||||
];
|
||||
}
|
||||
const hasPrintFlag = argv.some(
|
||||
(part) => part === "-p" || part === "--print",
|
||||
);
|
||||
if (!hasPrintFlag) {
|
||||
const insertIdx = Math.max(argv.length - 1, 0);
|
||||
argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)];
|
||||
}
|
||||
}
|
||||
const prefixedBody =
|
||||
transcribedText && reply?.mode === "command"
|
||||
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: prefixedBodyBase;
|
||||
const mediaNote = ctx.MediaPath?.length
|
||||
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
|
||||
: undefined;
|
||||
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
|
||||
const mediaReplyHint =
|
||||
mediaNote && reply?.mode === "command"
|
||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||
: undefined;
|
||||
const commandBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
: prefixedBody;
|
||||
const templatingCtx: TemplateContext = {
|
||||
...sessionCtx,
|
||||
Body: commandBody,
|
||||
BodyStripped: commandBody,
|
||||
};
|
||||
if (!reply) {
|
||||
logVerbose("No inbound.reply configured; skipping auto-reply");
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Inject session args if configured (use resume for existing, session-id for new)
|
||||
if (reply.session) {
|
||||
const sessionArgList = (
|
||||
isNewSession
|
||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
||||
).map((part) => applyTemplate(part, templatingCtx));
|
||||
if (sessionArgList.length) {
|
||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
||||
const insertAt =
|
||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
||||
argv = [
|
||||
...argv.slice(0, insertAt),
|
||||
...sessionArgList,
|
||||
...argv.slice(insertAt),
|
||||
];
|
||||
}
|
||||
}
|
||||
let finalArgv = argv;
|
||||
const isClaudeInvocation =
|
||||
finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN;
|
||||
if (isClaudeInvocation && finalArgv.length > 0) {
|
||||
const bodyIdx = finalArgv.length - 1;
|
||||
const existingBody = finalArgv[bodyIdx] ?? "";
|
||||
finalArgv = [
|
||||
...finalArgv.slice(0, bodyIdx),
|
||||
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
|
||||
];
|
||||
}
|
||||
logVerbose(`Running command auto-reply: ${finalArgv.join(" ")}`);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const { stdout, stderr, code, signal, killed } = await enqueueCommand(
|
||||
() => commandRunner(finalArgv, timeoutMs),
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (reply.mode === "text" && reply.text) {
|
||||
await onReplyStart();
|
||||
logVerbose("Using text auto-reply from config");
|
||||
const result = {
|
||||
text: applyTemplate(reply.text, templatingCtx),
|
||||
mediaUrl: reply.mediaUrl,
|
||||
};
|
||||
cleanupTyping();
|
||||
return result;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
if (reply && reply.mode === "command" && reply.command?.length) {
|
||||
await onReplyStart();
|
||||
const commandReply = {
|
||||
...reply,
|
||||
command: reply.command,
|
||||
mode: "command" as const,
|
||||
};
|
||||
try {
|
||||
const { payload, meta } = await runCommandReply({
|
||||
reply: commandReply,
|
||||
templatingCtx,
|
||||
sendSystemOnce,
|
||||
isNewSession,
|
||||
isFirstTurnInSession,
|
||||
systemSent,
|
||||
timeoutMs,
|
||||
timeoutSeconds,
|
||||
commandRunner,
|
||||
});
|
||||
if (meta.claudeMeta && isVerbose()) {
|
||||
logVerbose(`Claude JSON meta: ${meta.claudeMeta}`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
cleanupTyping();
|
||||
}
|
||||
}
|
||||
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type TwilioLikeClient = TwilioRequester & {
|
||||
messages: {
|
||||
create: (opts: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
body: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
messages: {
|
||||
create: (opts: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
body: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function autoReplyIfConfigured(
|
||||
client: TwilioLikeClient,
|
||||
message: MessageInstance,
|
||||
configOverride?: WarelayConfig,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
client: TwilioLikeClient,
|
||||
message: MessageInstance,
|
||||
configOverride?: WarelayConfig,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<void> {
|
||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||
const ctx: MsgContext = {
|
||||
Body: message.body ?? undefined,
|
||||
From: message.from ?? undefined,
|
||||
To: message.to ?? undefined,
|
||||
MessageSid: message.sid,
|
||||
};
|
||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||
const ctx: MsgContext = {
|
||||
Body: message.body ?? undefined,
|
||||
From: message.from ?? undefined,
|
||||
To: message.to ?? undefined,
|
||||
MessageSid: message.sid,
|
||||
};
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
// Attach media hints for transcription/templates if present on Twilio payloads.
|
||||
const mediaUrl = (message as { mediaUrl?: string }).mediaUrl;
|
||||
if (mediaUrl) ctx.MediaUrl = mediaUrl;
|
||||
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctx,
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
||||
},
|
||||
configOverride,
|
||||
);
|
||||
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) return;
|
||||
// Optional audio transcription before building reply.
|
||||
const mediaField = (message as { media?: unknown }).media;
|
||||
const mediaItems = Array.isArray(mediaField) ? mediaField : [];
|
||||
if (cfg.inbound?.transcribeAudio && mediaItems.length) {
|
||||
const media = mediaItems[0];
|
||||
const contentType = (media as { contentType?: string }).contentType;
|
||||
if (contentType?.startsWith("audio")) {
|
||||
const transcribed = await transcribeInboundAudio(cfg, ctx, runtime);
|
||||
if (transcribed?.text) {
|
||||
ctx.Body = transcribed.text;
|
||||
ctx.MediaType = contentType;
|
||||
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const replyFrom = message.to;
|
||||
const replyTo = message.from;
|
||||
if (!replyFrom || !replyTo) {
|
||||
if (isVerbose())
|
||||
console.error(
|
||||
"Skipping auto-reply: missing to/from on inbound message",
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctx,
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
if (
|
||||
!replyResult ||
|
||||
(!replyResult.text &&
|
||||
!replyResult.mediaUrl &&
|
||||
!replyResult.mediaUrls?.length)
|
||||
)
|
||||
return;
|
||||
|
||||
if (replyResult.text) {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
||||
);
|
||||
} else {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
||||
);
|
||||
}
|
||||
const replyFrom = message.to;
|
||||
const replyTo = message.from;
|
||||
if (!replyFrom || !replyTo) {
|
||||
if (isVerbose())
|
||||
console.error(
|
||||
"Skipping auto-reply: missing to/from on inbound message",
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: replyFrom,
|
||||
to: replyTo,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
info(
|
||||
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const anyErr = err as {
|
||||
code?: string | number;
|
||||
message?: unknown;
|
||||
moreInfo?: unknown;
|
||||
status?: string | number;
|
||||
response?: { body?: unknown };
|
||||
};
|
||||
const { code, status } = anyErr;
|
||||
const msg =
|
||||
typeof anyErr?.message === "string"
|
||||
? anyErr.message
|
||||
: (anyErr?.message ?? err);
|
||||
runtime.error(
|
||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
||||
);
|
||||
if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`);
|
||||
const responseBody = anyErr?.response?.body;
|
||||
if (responseBody) {
|
||||
runtime.error("Response body:");
|
||||
runtime.error(JSON.stringify(responseBody, null, 2));
|
||||
}
|
||||
}
|
||||
if (replyResult.text) {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
||||
);
|
||||
} else {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const mediaList = replyResult.mediaUrls?.length
|
||||
? replyResult.mediaUrls
|
||||
: replyResult.mediaUrl
|
||||
? [replyResult.mediaUrl]
|
||||
: [];
|
||||
|
||||
const sendTwilio = async (body: string, media?: string) => {
|
||||
let resolvedMedia = media;
|
||||
if (resolvedMedia && !/^https?:\/\//i.test(resolvedMedia)) {
|
||||
const hosted = await ensureMediaHosted(resolvedMedia);
|
||||
resolvedMedia = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: replyFrom,
|
||||
to: replyTo,
|
||||
body,
|
||||
...(resolvedMedia ? { mediaUrl: [resolvedMedia] } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
await sendTwilio(replyResult.text ?? "");
|
||||
} else {
|
||||
// First media with body (if any), then remaining as separate media-only sends.
|
||||
await sendTwilio(replyResult.text ?? "", mediaList[0]);
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
await sendTwilio("", extra);
|
||||
}
|
||||
}
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
info(
|
||||
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const anyErr = err as {
|
||||
code?: string | number;
|
||||
message?: unknown;
|
||||
moreInfo?: unknown;
|
||||
status?: string | number;
|
||||
response?: { body?: unknown };
|
||||
};
|
||||
const { code, status } = anyErr;
|
||||
const msg =
|
||||
typeof anyErr?.message === "string"
|
||||
? anyErr.message
|
||||
: (anyErr?.message ?? err);
|
||||
runtime.error(
|
||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
||||
);
|
||||
if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`);
|
||||
const responseBody = anyErr?.response?.body;
|
||||
if (responseBody) {
|
||||
runtime.error("Response body:");
|
||||
runtime.error(JSON.stringify(responseBody, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
export type MsgContext = {
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
MessageSid?: string;
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
MessageSid?: string;
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
Transcript?: string;
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
BodyStripped?: string;
|
||||
SessionId?: string;
|
||||
IsNewSession?: string;
|
||||
BodyStripped?: string;
|
||||
SessionId?: string;
|
||||
IsNewSession?: string;
|
||||
};
|
||||
|
||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||
export function applyTemplate(str: string, ctx: TemplateContext) {
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
const value = (ctx as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
const value = (ctx as Record<string, unknown>)[key];
|
||||
return value == null ? "" : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { transcribeInboundAudio } from "./transcription.js";
|
||||
|
||||
vi.mock("../globals.js", () => ({
|
||||
isVerbose: () => false,
|
||||
logVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runExec: vi.fn(async () => ({ stdout: "transcribed text\n" })),
|
||||
}));
|
||||
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
describe("transcribeInboundAudio", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("downloads mediaUrl to temp file and returns transcript", async () => {
|
||||
const tmpBuf = Buffer.from("audio-bytes");
|
||||
const tmpFile = path.join(os.tmpdir(), `warelay-audio-${Date.now()}.ogg`);
|
||||
await fs.writeFile(tmpFile, tmpBuf);
|
||||
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => tmpBuf,
|
||||
})) as unknown as typeof fetch;
|
||||
// @ts-expect-error override global fetch for test
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const cfg = {
|
||||
inbound: {
|
||||
transcribeAudio: {
|
||||
command: ["echo", "{{MediaPath}}"],
|
||||
timeoutSeconds: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx = { MediaUrl: "https://example.com/audio.ogg" };
|
||||
|
||||
const result = await transcribeInboundAudio(
|
||||
cfg as never,
|
||||
ctx as never,
|
||||
runtime as never,
|
||||
);
|
||||
expect(result?.text).toBe("transcribed text");
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when no transcription command", async () => {
|
||||
const res = await transcribeInboundAudio(
|
||||
{ inbound: {} } as never,
|
||||
{} as never,
|
||||
runtime as never,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { WarelayConfig } from "../config/config.js";
|
||||
import { isVerbose, logVerbose } from "../globals.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { applyTemplate, type MsgContext } from "./templating.js";
|
||||
|
||||
export function isAudio(mediaType?: string | null) {
|
||||
return Boolean(mediaType?.startsWith("audio"));
|
||||
}
|
||||
|
||||
export async function transcribeInboundAudio(
|
||||
cfg: WarelayConfig,
|
||||
ctx: MsgContext,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<{ text: string } | undefined> {
|
||||
const transcriber = cfg.inbound?.transcribeAudio;
|
||||
if (!transcriber?.command?.length) return undefined;
|
||||
|
||||
const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000);
|
||||
let tmpPath: string | undefined;
|
||||
let mediaPath = ctx.MediaPath;
|
||||
try {
|
||||
if (!mediaPath && ctx.MediaUrl) {
|
||||
const res = await fetch(ctx.MediaUrl);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const arrayBuf = await res.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuf);
|
||||
tmpPath = path.join(
|
||||
os.tmpdir(),
|
||||
`warelay-audio-${crypto.randomUUID()}.ogg`,
|
||||
);
|
||||
await fs.writeFile(tmpPath, buffer);
|
||||
mediaPath = tmpPath;
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Downloaded audio for transcription (${(buffer.length / (1024 * 1024)).toFixed(2)}MB) -> ${tmpPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!mediaPath) return undefined;
|
||||
|
||||
const templCtx: MsgContext = { ...ctx, MediaPath: mediaPath };
|
||||
const argv = transcriber.command.map((part) =>
|
||||
applyTemplate(part, templCtx),
|
||||
);
|
||||
if (isVerbose()) {
|
||||
logVerbose(`Transcribing audio via command: ${argv.join(" ")}`);
|
||||
}
|
||||
const { stdout } = await runExec(argv[0], argv.slice(1), {
|
||||
timeoutMs,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
});
|
||||
const text = stdout.trim();
|
||||
if (!text) return undefined;
|
||||
return { text };
|
||||
} catch (err) {
|
||||
runtime.error?.(`Audio transcription failed: ${String(err)}`);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (tmpPath) {
|
||||
void fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ReplyPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
+76
-76
@@ -6,9 +6,9 @@ import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
|
||||
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import {
|
||||
logWebSelfId,
|
||||
monitorWebProvider,
|
||||
sendMessageWeb,
|
||||
logWebSelfId,
|
||||
monitorWebProvider,
|
||||
sendMessageWeb,
|
||||
} from "../providers/web/index.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { createClient } from "../twilio/client.js";
|
||||
@@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js";
|
||||
import { waitForever } from "./wait.js";
|
||||
|
||||
export type CliDeps = {
|
||||
sendMessage: typeof sendMessage;
|
||||
sendMessageWeb: typeof sendMessageWeb;
|
||||
waitForFinalStatus: typeof waitForFinalStatus;
|
||||
assertProvider: typeof assertProvider;
|
||||
createClient?: typeof createClient;
|
||||
monitorTwilio: typeof monitorTwilio;
|
||||
listRecentMessages: typeof listRecentMessages;
|
||||
ensurePortAvailable: typeof ensurePortAvailable;
|
||||
startWebhook: typeof startWebhook;
|
||||
waitForever: typeof waitForever;
|
||||
ensureBinary: typeof ensureBinary;
|
||||
ensureFunnel: typeof ensureFunnel;
|
||||
getTailnetHostname: typeof getTailnetHostname;
|
||||
readEnv: typeof readEnv;
|
||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||
updateWebhook: typeof updateWebhook;
|
||||
handlePortError: typeof handlePortError;
|
||||
monitorWebProvider: typeof monitorWebProvider;
|
||||
resolveTwilioMediaUrl: (
|
||||
source: string,
|
||||
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
||||
) => Promise<string>;
|
||||
sendMessage: typeof sendMessage;
|
||||
sendMessageWeb: typeof sendMessageWeb;
|
||||
waitForFinalStatus: typeof waitForFinalStatus;
|
||||
assertProvider: typeof assertProvider;
|
||||
createClient?: typeof createClient;
|
||||
monitorTwilio: typeof monitorTwilio;
|
||||
listRecentMessages: typeof listRecentMessages;
|
||||
ensurePortAvailable: typeof ensurePortAvailable;
|
||||
startWebhook: typeof startWebhook;
|
||||
waitForever: typeof waitForever;
|
||||
ensureBinary: typeof ensureBinary;
|
||||
ensureFunnel: typeof ensureFunnel;
|
||||
getTailnetHostname: typeof getTailnetHostname;
|
||||
readEnv: typeof readEnv;
|
||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||
updateWebhook: typeof updateWebhook;
|
||||
handlePortError: typeof handlePortError;
|
||||
monitorWebProvider: typeof monitorWebProvider;
|
||||
resolveTwilioMediaUrl: (
|
||||
source: string,
|
||||
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
||||
) => Promise<string>;
|
||||
};
|
||||
|
||||
export async function monitorTwilio(
|
||||
intervalSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
maxIterations = Infinity,
|
||||
intervalSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
maxIterations = Infinity,
|
||||
) {
|
||||
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
||||
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
||||
client: clientOverride,
|
||||
maxIterations,
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
runtime: defaultRuntime,
|
||||
});
|
||||
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
||||
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
||||
client: clientOverride,
|
||||
maxIterations,
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
runtime: defaultRuntime,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDefaultDeps(): CliDeps {
|
||||
// Default dependency bundle used by CLI commands and tests.
|
||||
return {
|
||||
sendMessage,
|
||||
sendMessageWeb,
|
||||
waitForFinalStatus,
|
||||
assertProvider,
|
||||
createClient,
|
||||
monitorTwilio,
|
||||
listRecentMessages,
|
||||
ensurePortAvailable,
|
||||
startWebhook,
|
||||
waitForever,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
getTailnetHostname,
|
||||
readEnv,
|
||||
findWhatsappSenderSid,
|
||||
updateWebhook,
|
||||
handlePortError,
|
||||
monitorWebProvider,
|
||||
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
||||
if (/^https?:\/\//i.test(source)) return source;
|
||||
const hosted = await ensureMediaHosted(source, {
|
||||
startServer: serveMedia,
|
||||
runtime,
|
||||
});
|
||||
return hosted.url;
|
||||
},
|
||||
};
|
||||
// Default dependency bundle used by CLI commands and tests.
|
||||
return {
|
||||
sendMessage,
|
||||
sendMessageWeb,
|
||||
waitForFinalStatus,
|
||||
assertProvider,
|
||||
createClient,
|
||||
monitorTwilio,
|
||||
listRecentMessages,
|
||||
ensurePortAvailable,
|
||||
startWebhook,
|
||||
waitForever,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
getTailnetHostname,
|
||||
readEnv,
|
||||
findWhatsappSenderSid,
|
||||
updateWebhook,
|
||||
handlePortError,
|
||||
monitorWebProvider,
|
||||
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
||||
if (/^https?:\/\//i.test(source)) return source;
|
||||
const hosted = await ensureMediaHosted(source, {
|
||||
startServer: serveMedia,
|
||||
runtime,
|
||||
});
|
||||
return hosted.url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
||||
// Log the configured Twilio sender for clarity in CLI output.
|
||||
const env = readEnv(runtime);
|
||||
runtime.log(
|
||||
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
||||
);
|
||||
// Log the configured Twilio sender for clarity in CLI output.
|
||||
const env = readEnv(runtime);
|
||||
runtime.log(
|
||||
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
||||
);
|
||||
}
|
||||
|
||||
export { logWebSelfId };
|
||||
|
||||
+92
-52
@@ -14,11 +14,11 @@ const waitForever = vi.fn();
|
||||
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||
@@ -27,63 +27,103 @@ vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
|
||||
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||
vi.mock("../provider-web.js", () => ({
|
||||
loginWeb,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
loginWeb,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
}));
|
||||
vi.mock("./deps.js", () => ({
|
||||
createDefaultDeps: () => ({ waitForever }),
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
createDefaultDeps: () => ({ waitForever }),
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
}));
|
||||
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
describe("cli program", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("runs send with required options", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(sendCommand).toHaveBeenCalled();
|
||||
});
|
||||
it("runs send with required options", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
||||
from: "user",
|
||||
});
|
||||
expect(sendCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid relay provider", async () => {
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"--provider must be auto, web, or twilio",
|
||||
);
|
||||
});
|
||||
it("rejects invalid relay provider", async () => {
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"--provider must be auto, web, or twilio",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to twilio when web relay fails", async () => {
|
||||
pickProvider.mockResolvedValue("web");
|
||||
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(ensureTwilioEnv).toHaveBeenCalled();
|
||||
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
|
||||
});
|
||||
it("falls back to twilio when web relay fails", async () => {
|
||||
pickProvider.mockResolvedValue("web");
|
||||
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("exit");
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(ensureTwilioEnv).not.toHaveBeenCalled();
|
||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs relay tmux attach command", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||
"pnpm warelay relay --verbose",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("runs relay tmux attach command", async () => {
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||
true;
|
||||
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||
"pnpm warelay relay --verbose",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||
originalIsTTY;
|
||||
});
|
||||
|
||||
it("runs relay heartbeat command", async () => {
|
||||
pickProvider.mockResolvedValue("web");
|
||||
monitorWebProvider.mockResolvedValue(undefined);
|
||||
const originalExit = runtime.exit;
|
||||
runtime.exit = vi.fn();
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:heartbeat"], { from: "user" });
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(monitorWebProvider).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
runtime,
|
||||
undefined,
|
||||
{ replyHeartbeatNow: true },
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
runtime.exit = originalExit;
|
||||
});
|
||||
|
||||
it("runs relay heartbeat tmux helper", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:heartbeat:tmux"], { from: "user" });
|
||||
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||
"pnpm warelay relay --verbose --heartbeat-now",
|
||||
shouldAttach,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+624
-284
@@ -3,333 +3,673 @@ import { Command } from "commander";
|
||||
import { sendCommand } from "../commands/send.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { webhookCommand } from "../commands/webhook.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { ensureTwilioEnv } from "../env.js";
|
||||
import { danger, info, setVerbose, setYes, warn } from "../globals.js";
|
||||
import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { Provider } from "../utils.js";
|
||||
import { danger, info, setVerbose, setYes } from "../globals.js";
|
||||
import { getResolvedLoggerSettings } from "../logging.js";
|
||||
import {
|
||||
createDefaultDeps,
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
loginWeb,
|
||||
logoutWeb,
|
||||
monitorWebProvider,
|
||||
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";
|
||||
import { spawnRelayTmux } from "./relay_tmux.js";
|
||||
|
||||
export function buildProgram() {
|
||||
const program = new Command();
|
||||
const PROGRAM_VERSION = "0.1.2";
|
||||
const TAGLINE =
|
||||
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
||||
const program = new Command();
|
||||
const PROGRAM_VERSION = VERSION;
|
||||
const TAGLINE =
|
||||
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
||||
|
||||
program
|
||||
.name("warelay")
|
||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||
.version(PROGRAM_VERSION);
|
||||
program
|
||||
.name("warelay")
|
||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||
.version(PROGRAM_VERSION);
|
||||
|
||||
const formatIntroLine = (version: string, rich = true) => {
|
||||
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
||||
return rich && chalk.level > 0
|
||||
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
||||
: base;
|
||||
};
|
||||
const formatIntroLine = (version: string, rich = true) => {
|
||||
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
||||
return rich && chalk.level > 0
|
||||
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
||||
: base;
|
||||
};
|
||||
|
||||
program.configureHelp({
|
||||
optionTerm: (option) => chalk.yellow(option.flags),
|
||||
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
||||
});
|
||||
program.configureHelp({
|
||||
optionTerm: (option) => chalk.yellow(option.flags),
|
||||
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
||||
});
|
||||
|
||||
program.configureOutput({
|
||||
writeOut: (str) => {
|
||||
const colored = str
|
||||
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
||||
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
||||
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
||||
process.stdout.write(colored);
|
||||
},
|
||||
writeErr: (str) => process.stderr.write(str),
|
||||
outputError: (str, write) => write(chalk.red(str)),
|
||||
});
|
||||
program.configureOutput({
|
||||
writeOut: (str) => {
|
||||
const colored = str
|
||||
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
||||
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
||||
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
||||
process.stdout.write(colored);
|
||||
},
|
||||
writeErr: (str) => process.stderr.write(str),
|
||||
outputError: (str, write) => write(chalk.red(str)),
|
||||
});
|
||||
|
||||
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
||||
console.log(formatIntroLine(PROGRAM_VERSION));
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
||||
console.log(formatIntroLine(PROGRAM_VERSION));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||
const examples = [
|
||||
[
|
||||
"warelay login --verbose",
|
||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||
],
|
||||
[
|
||||
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
||||
"Send via your web session and print JSON result.",
|
||||
],
|
||||
[
|
||||
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
||||
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
||||
],
|
||||
[
|
||||
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
||||
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
||||
],
|
||||
[
|
||||
"warelay status --limit 10 --lookback 60 --json",
|
||||
"Show last 10 messages from the past hour as JSON.",
|
||||
],
|
||||
] as const;
|
||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||
const examples = [
|
||||
[
|
||||
"warelay login --verbose",
|
||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||
],
|
||||
[
|
||||
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
||||
"Send via your web session and print JSON result.",
|
||||
],
|
||||
[
|
||||
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
||||
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
||||
],
|
||||
[
|
||||
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
||||
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
||||
],
|
||||
[
|
||||
"warelay status --limit 10 --lookback 60 --json",
|
||||
"Show last 10 messages from the past hour as JSON.",
|
||||
],
|
||||
] as const;
|
||||
|
||||
const fmtExamples = examples
|
||||
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
||||
.join("\n");
|
||||
const fmtExamples = examples
|
||||
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
||||
.join("\n");
|
||||
|
||||
program.addHelpText(
|
||||
"afterAll",
|
||||
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
||||
);
|
||||
program.addHelpText(
|
||||
"afterAll",
|
||||
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
||||
);
|
||||
|
||||
program
|
||||
.command("login")
|
||||
.description("Link your personal WhatsApp via QR (web provider)")
|
||||
.option("--verbose", "Verbose connection logs", false)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
try {
|
||||
await loginWeb(Boolean(opts.verbose));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command("login")
|
||||
.description("Link your personal WhatsApp via QR (web provider)")
|
||||
.option("--verbose", "Verbose connection logs", false)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
try {
|
||||
await loginWeb(Boolean(opts.verbose));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("send")
|
||||
.description("Send a WhatsApp message")
|
||||
.requiredOption(
|
||||
"-t, --to <number>",
|
||||
"Recipient number in E.164 (e.g. +15551234567)",
|
||||
)
|
||||
.requiredOption("-m, --message <text>", "Message body")
|
||||
.option(
|
||||
"--media <path-or-url>",
|
||||
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
||||
)
|
||||
.option(
|
||||
"--serve-media",
|
||||
"For Twilio: start a temporary media server if webhook is not running",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"-w, --wait <seconds>",
|
||||
"Wait for delivery status (0 to skip)",
|
||||
"20",
|
||||
)
|
||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||
.option("--dry-run", "Print payload and skip sending", false)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
program
|
||||
.command("logout")
|
||||
.description("Clear cached WhatsApp Web credentials")
|
||||
.action(async () => {
|
||||
try {
|
||||
await logoutWeb(defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("send")
|
||||
.description("Send a WhatsApp message")
|
||||
.requiredOption(
|
||||
"-t, --to <number>",
|
||||
"Recipient number in E.164 (e.g. +15551234567)",
|
||||
)
|
||||
.requiredOption("-m, --message <text>", "Message body")
|
||||
.option(
|
||||
"--media <path-or-url>",
|
||||
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
||||
)
|
||||
.option(
|
||||
"--serve-media",
|
||||
"For Twilio: start a temporary media server if webhook is not running",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"-w, --wait <seconds>",
|
||||
"Wait for delivery status (0 to skip)",
|
||||
"20",
|
||||
)
|
||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||
.option("--dry-run", "Print payload and skip sending", false)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
|
||||
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
|
||||
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
|
||||
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await sendCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await sendCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay")
|
||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
||||
.option(
|
||||
"-l, --lookback <minutes>",
|
||||
"Initial lookback window for twilio mode",
|
||||
"5",
|
||||
)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
program
|
||||
.command("heartbeat")
|
||||
.description(
|
||||
"Trigger a heartbeat or manual send once (web or twilio, no tmux)",
|
||||
)
|
||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
||||
.option(
|
||||
"--session-id <id>",
|
||||
"Force a session id for this heartbeat (resumes a specific Claude session)",
|
||||
)
|
||||
.option(
|
||||
"--all",
|
||||
"Send heartbeat to all active sessions (or allowFrom entries when none)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--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:
|
||||
warelay relay # auto: web if logged-in, else twilio poll
|
||||
warelay relay --provider web # force personal web session
|
||||
warelay relay --provider twilio # force twilio poll
|
||||
warelay relay --provider twilio --interval 2 --lookback 30
|
||||
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
|
||||
`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const providerPref = String(opts.provider ?? "auto");
|
||||
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
||||
defaultRuntime.error("Interval must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
||||
defaultRuntime.error("Lookback must be >= 0 minutes");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
|
||||
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
|
||||
const providerPref = String(opts.provider ?? "auto");
|
||||
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
const webHeartbeat =
|
||||
opts.webHeartbeat !== undefined
|
||||
? Number.parseInt(String(opts.webHeartbeat), 10)
|
||||
: undefined;
|
||||
const webRetries =
|
||||
opts.webRetries !== undefined
|
||||
? Number.parseInt(String(opts.webRetries), 10)
|
||||
: undefined;
|
||||
const webRetryInitial =
|
||||
opts.webRetryInitial !== undefined
|
||||
? Number.parseInt(String(opts.webRetryInitial), 10)
|
||||
: undefined;
|
||||
const webRetryMax =
|
||||
opts.webRetryMax !== undefined
|
||||
? Number.parseInt(String(opts.webRetryMax), 10)
|
||||
: undefined;
|
||||
const heartbeatNow = Boolean(opts.heartbeatNow);
|
||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
||||
defaultRuntime.error("Interval must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
||||
defaultRuntime.error("Lookback must be >= 0 minutes");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webHeartbeat !== undefined &&
|
||||
(Number.isNaN(webHeartbeat) || webHeartbeat <= 0)
|
||||
) {
|
||||
defaultRuntime.error("--web-heartbeat must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webRetries !== undefined &&
|
||||
(Number.isNaN(webRetries) || webRetries < 0)
|
||||
) {
|
||||
defaultRuntime.error("--web-retries must be >= 0");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webRetryInitial !== undefined &&
|
||||
(Number.isNaN(webRetryInitial) || webRetryInitial <= 0)
|
||||
) {
|
||||
defaultRuntime.error("--web-retry-initial must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webRetryMax !== undefined &&
|
||||
(Number.isNaN(webRetryMax) || webRetryMax <= 0)
|
||||
) {
|
||||
defaultRuntime.error("--web-retry-max must be a positive integer");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (
|
||||
webRetryMax !== undefined &&
|
||||
webRetryInitial !== undefined &&
|
||||
webRetryMax < webRetryInitial
|
||||
) {
|
||||
defaultRuntime.error("--web-retry-max must be >= --web-retry-initial");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||
const webTuning: WebMonitorTuning = {};
|
||||
if (webHeartbeat !== undefined) webTuning.heartbeatSeconds = webHeartbeat;
|
||||
if (heartbeatNow) webTuning.replyHeartbeatNow = true;
|
||||
const reconnect: WebMonitorTuning["reconnect"] = {};
|
||||
if (webRetries !== undefined) reconnect.maxAttempts = webRetries;
|
||||
if (webRetryInitial !== undefined) reconnect.initialMs = webRetryInitial;
|
||||
if (webRetryMax !== undefined) reconnect.maxMs = webRetryMax;
|
||||
if (Object.keys(reconnect).length > 0) {
|
||||
webTuning.reconnect = reconnect;
|
||||
}
|
||||
|
||||
if (provider === "web") {
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
try {
|
||||
await monitorWebProvider(Boolean(opts.verbose));
|
||||
return;
|
||||
} catch (err) {
|
||||
if (providerPref === "auto") {
|
||||
defaultRuntime.error(
|
||||
warn("Web session unavailable; falling back to twilio."),
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||
|
||||
ensureTwilioEnv();
|
||||
logTwilioFrom();
|
||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||
});
|
||||
if (provider === "web") {
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
const cfg = loadConfig();
|
||||
const effectiveHeartbeat = resolveHeartbeatSeconds(
|
||||
cfg,
|
||||
webTuning.heartbeatSeconds,
|
||||
);
|
||||
const effectivePolicy = resolveReconnectPolicy(
|
||||
cfg,
|
||||
webTuning.reconnect,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await monitorWebProvider(
|
||||
Boolean(opts.verbose),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
defaultRuntime,
|
||||
undefined,
|
||||
webTuning,
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Web relay failed: ${String(err)}. Not falling back; re-link with 'warelay login --provider web'.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Show recent WhatsApp messages (sent and received)")
|
||||
.option("-l, --limit <count>", "Number of messages to show", "20")
|
||||
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
ensureTwilioEnv();
|
||||
logTwilioFrom();
|
||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay:heartbeat")
|
||||
.description(
|
||||
"Run relay with an immediate heartbeat (no tmux); requires web provider",
|
||||
)
|
||||
.option("--provider <provider>", "auto | web", "auto")
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
|
||||
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
|
||||
const providerPref = String(opts.provider ?? "auto");
|
||||
if (!["auto", "web"].includes(providerPref)) {
|
||||
defaultRuntime.error("--provider must be auto or web");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const provider = await pickProvider(providerPref as "auto" | "web");
|
||||
if (provider !== "web") {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"Heartbeat relay is only supported for the web provider. Link with `warelay login --verbose`.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
const cfg = loadConfig();
|
||||
const effectiveHeartbeat = resolveHeartbeatSeconds(cfg, undefined);
|
||||
const effectivePolicy = resolveReconnectPolicy(cfg, undefined);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await monitorWebProvider(
|
||||
Boolean(opts.verbose),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
defaultRuntime,
|
||||
undefined,
|
||||
{ replyHeartbeatNow: true },
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Web relay failed: ${String(err)}. Re-link with 'warelay login --provider web'.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Show recent WhatsApp messages (sent and received)")
|
||||
.option("-l, --limit <count>", "Number of messages to show", "20")
|
||||
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
warelay status # last 20 msgs in past 4h
|
||||
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
||||
warelay status --json --limit 50 # machine-readable output`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await statusCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await statusCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("webhook")
|
||||
.description(
|
||||
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
||||
)
|
||||
.option("-p, --port <port>", "Port to listen on", "42873")
|
||||
.option("-r, --reply <text>", "Optional auto-reply text")
|
||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||
.option(
|
||||
"--ingress <mode>",
|
||||
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
||||
"tailscale",
|
||||
)
|
||||
.option("--verbose", "Log inbound and auto-replies", false)
|
||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||
.option("--dry-run", "Print planned actions without starting server", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
program
|
||||
.command("webhook")
|
||||
.description(
|
||||
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
||||
)
|
||||
.option("-p, --port <port>", "Port to listen on", "42873")
|
||||
.option("-r, --reply <text>", "Optional auto-reply text")
|
||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||
.option(
|
||||
"--ingress <mode>",
|
||||
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
||||
"tailscale",
|
||||
)
|
||||
.option("--verbose", "Log inbound and auto-replies", false)
|
||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||
.option("--dry-run", "Print planned actions without starting server", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
warelay webhook # ingress=tailscale (funnel + Twilio update)
|
||||
warelay webhook --ingress none # local-only server (no funnel / no Twilio update)
|
||||
warelay webhook --port 45000 # pick a high, less-colliding port
|
||||
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
||||
)
|
||||
// istanbul ignore next
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
setYes(Boolean(opts.yes));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||
if (!server) {
|
||||
defaultRuntime.log(
|
||||
info("Webhook dry-run complete; no server started."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.on("SIGINT", () => {
|
||||
server.close(() => {
|
||||
console.log("\n👋 Webhook stopped");
|
||||
defaultRuntime.exit(0);
|
||||
});
|
||||
});
|
||||
await deps.waitForever();
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
)
|
||||
// istanbul ignore next
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
setYes(Boolean(opts.yes));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||
if (!server) {
|
||||
defaultRuntime.log(
|
||||
info("Webhook dry-run complete; no server started."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.on("SIGINT", () => {
|
||||
server.close(() => {
|
||||
console.log("\n👋 Webhook stopped");
|
||||
defaultRuntime.exit(0);
|
||||
});
|
||||
});
|
||||
await deps.waitForever();
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay:tmux")
|
||||
.description(
|
||||
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const session = await spawnRelayTmux(
|
||||
"pnpm warelay relay --verbose",
|
||||
true,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Failed to start relay tmux session: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command("relay:tmux")
|
||||
.description(
|
||||
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||
const session = await spawnRelayTmux(
|
||||
"pnpm warelay relay --verbose",
|
||||
shouldAttach,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
shouldAttach
|
||||
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`
|
||||
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose"); attach manually with "tmux attach -t ${session}"`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Failed to start relay tmux session: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("relay:tmux:attach")
|
||||
.description(
|
||||
"Attach to the existing warelay-relay tmux session (no restart)",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command("relay:tmux:attach")
|
||||
.description(
|
||||
"Attach to the existing warelay-relay tmux session (no restart)",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
if (!process.stdout.isTTY) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"Cannot attach: stdout is not a TTY. Run this in a terminal or use 'tmux attach -t warelay-relay' manually.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
return program;
|
||||
program
|
||||
.command("relay:heartbeat:tmux")
|
||||
.description(
|
||||
"Run relay --verbose with an immediate heartbeat inside tmux (session warelay-relay), then attach",
|
||||
)
|
||||
.action(async () => {
|
||||
try {
|
||||
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||
const session = await spawnRelayTmux(
|
||||
"pnpm warelay relay --verbose --heartbeat-now",
|
||||
shouldAttach,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
shouldAttach
|
||||
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")`
|
||||
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now"); attach manually with "tmux attach -t ${session}"`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Failed to start relay tmux session with heartbeat: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
+31
-31
@@ -3,47 +3,47 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { isYes, setVerbose, setYes } from "../globals.js";
|
||||
|
||||
vi.mock("node:readline/promises", () => {
|
||||
const question = vi.fn<[], Promise<string>>();
|
||||
const close = vi.fn();
|
||||
const createInterface = vi.fn(() => ({ question, close }));
|
||||
return { default: { createInterface } };
|
||||
const question = vi.fn<[], Promise<string>>();
|
||||
const close = vi.fn();
|
||||
const createInterface = vi.fn(() => ({ question, close }));
|
||||
return { default: { createInterface } };
|
||||
});
|
||||
|
||||
type ReadlineMock = {
|
||||
default: {
|
||||
createInterface: () => {
|
||||
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
default: {
|
||||
createInterface: () => {
|
||||
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const { promptYesNo } = await import("./prompt.js");
|
||||
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
||||
|
||||
describe("promptYesNo", () => {
|
||||
it("returns true when global --yes is set", async () => {
|
||||
setYes(true);
|
||||
setVerbose(false);
|
||||
const result = await promptYesNo("Continue?");
|
||||
expect(result).toBe(true);
|
||||
expect(isYes()).toBe(true);
|
||||
});
|
||||
it("returns true when global --yes is set", async () => {
|
||||
setYes(true);
|
||||
setVerbose(false);
|
||||
const result = await promptYesNo("Continue?");
|
||||
expect(result).toBe(true);
|
||||
expect(isYes()).toBe(true);
|
||||
});
|
||||
|
||||
it("asks the question and respects default", async () => {
|
||||
setYes(false);
|
||||
setVerbose(false);
|
||||
const { question: questionMock } = readline.default.createInterface();
|
||||
questionMock.mockResolvedValueOnce("");
|
||||
const resultDefaultYes = await promptYesNo("Continue?", true);
|
||||
expect(resultDefaultYes).toBe(true);
|
||||
it("asks the question and respects default", async () => {
|
||||
setYes(false);
|
||||
setVerbose(false);
|
||||
const { question: questionMock } = readline.default.createInterface();
|
||||
questionMock.mockResolvedValueOnce("");
|
||||
const resultDefaultYes = await promptYesNo("Continue?", true);
|
||||
expect(resultDefaultYes).toBe(true);
|
||||
|
||||
questionMock.mockResolvedValueOnce("n");
|
||||
const resultNo = await promptYesNo("Continue?", true);
|
||||
expect(resultNo).toBe(false);
|
||||
questionMock.mockResolvedValueOnce("n");
|
||||
const resultNo = await promptYesNo("Continue?", true);
|
||||
expect(resultNo).toBe(false);
|
||||
|
||||
questionMock.mockResolvedValueOnce("y");
|
||||
const resultYes = await promptYesNo("Continue?", false);
|
||||
expect(resultYes).toBe(true);
|
||||
});
|
||||
questionMock.mockResolvedValueOnce("y");
|
||||
const resultYes = await promptYesNo("Continue?", false);
|
||||
expect(resultYes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-13
@@ -4,18 +4,18 @@ import readline from "node:readline/promises";
|
||||
import { isVerbose, isYes } from "../globals.js";
|
||||
|
||||
export async function promptYesNo(
|
||||
question: string,
|
||||
defaultYes = false,
|
||||
question: string,
|
||||
defaultYes = false,
|
||||
): Promise<boolean> {
|
||||
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
||||
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
||||
if (isYes()) return true;
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||
const answer = (await rl.question(`${question}${suffix}`))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
rl.close();
|
||||
if (!answer) return defaultYes;
|
||||
return answer.startsWith("y");
|
||||
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
||||
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
||||
if (isYes()) return true;
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||
const answer = (await rl.question(`${question}${suffix}`))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
rl.close();
|
||||
if (!answer) return defaultYes;
|
||||
return answer.startsWith("y");
|
||||
}
|
||||
|
||||
+27
-26
@@ -2,43 +2,44 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
|
||||
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
||||
vi.hoisted(() => {
|
||||
return {
|
||||
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
||||
pickProvider: vi.fn().mockResolvedValue("web"),
|
||||
logWebSelfId: vi.fn(),
|
||||
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
vi.hoisted(() => {
|
||||
return {
|
||||
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
||||
pickProvider: vi.fn().mockResolvedValue("web"),
|
||||
logWebSelfId: vi.fn(),
|
||||
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../provider-web.js", () => ({
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
logWebSelfId,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
logWebSelfId,
|
||||
}));
|
||||
|
||||
vi.mock("../twilio/monitor.js", () => ({
|
||||
monitorTwilio,
|
||||
monitorTwilio,
|
||||
}));
|
||||
|
||||
import { buildProgram } from "./program.js";
|
||||
|
||||
describe("CLI relay command (e2e-ish)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("runs relay in web mode without crashing", async () => {
|
||||
const program = buildProgram();
|
||||
program.exitOverride(); // throw instead of exiting process on error
|
||||
it("runs relay in web mode without crashing", async () => {
|
||||
const program = buildProgram();
|
||||
program.exitOverride(); // throw instead of exiting process on error
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
||||
).resolves.toBeInstanceOf(Object);
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
||||
).resolves.toBeInstanceOf(Object);
|
||||
|
||||
expect(pickProvider).toHaveBeenCalledWith("web");
|
||||
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
||||
expect(monitorWebProvider).toHaveBeenCalledWith(false);
|
||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(pickProvider).toHaveBeenCalledWith("web");
|
||||
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
||||
expect(monitorWebProvider).toHaveBeenCalledTimes(1);
|
||||
expect(monitorWebProvider.mock.calls[0][0]).toBe(false);
|
||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+33
-33
@@ -3,45 +3,45 @@ import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
proc.emit("exit", 0);
|
||||
});
|
||||
proc.kill = vi.fn();
|
||||
return proc;
|
||||
});
|
||||
return { spawn };
|
||||
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
proc.emit("exit", 0);
|
||||
});
|
||||
proc.kill = vi.fn();
|
||||
return proc;
|
||||
});
|
||||
return { spawn };
|
||||
});
|
||||
|
||||
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
describe("spawnRelayTmux", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("kills old session, starts new one, and attaches", async () => {
|
||||
const session = await spawnRelayTmux("echo hi", true, true);
|
||||
expect(session).toBe("warelay-relay");
|
||||
const spawnMock = spawn as unknown as vi.Mock;
|
||||
expect(spawnMock.mock.calls.length).toBe(3);
|
||||
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
||||
expect(calls[0][0]).toBe("tmux"); // kill-session
|
||||
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
||||
expect(calls[2][1][0]).toBe("attach-session");
|
||||
});
|
||||
it("kills old session, starts new one, and attaches", async () => {
|
||||
const session = await spawnRelayTmux("echo hi", true, true);
|
||||
expect(session).toBe("warelay-relay");
|
||||
const spawnMock = spawn as unknown as vi.Mock;
|
||||
expect(spawnMock.mock.calls.length).toBe(3);
|
||||
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
||||
expect(calls[0][0]).toBe("tmux"); // kill-session
|
||||
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
||||
expect(calls[2][1][0]).toBe("attach-session");
|
||||
});
|
||||
|
||||
it("can skip attach", async () => {
|
||||
await spawnRelayTmux("echo hi", false, true);
|
||||
const spawnMock = spawn as unknown as vi.Mock;
|
||||
const hasAttach = spawnMock.mock.calls.some(
|
||||
(c) =>
|
||||
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
||||
);
|
||||
expect(hasAttach).toBe(false);
|
||||
});
|
||||
it("can skip attach", async () => {
|
||||
await spawnRelayTmux("echo hi", false, true);
|
||||
const spawnMock = spawn as unknown as vi.Mock;
|
||||
const hasAttach = spawnMock.mock.calls.some(
|
||||
(c) =>
|
||||
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
||||
);
|
||||
expect(hasAttach).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-38
@@ -3,48 +3,48 @@ import { spawn } from "node:child_process";
|
||||
const SESSION = "warelay-relay";
|
||||
|
||||
export async function spawnRelayTmux(
|
||||
cmd = "pnpm warelay relay --verbose",
|
||||
attach = true,
|
||||
restart = true,
|
||||
cmd = "pnpm warelay relay --verbose",
|
||||
attach = true,
|
||||
restart = true,
|
||||
) {
|
||||
if (restart) {
|
||||
await killSession(SESSION);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tmux exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
if (restart) {
|
||||
await killSession(SESSION);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tmux exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (attach) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tmux attach exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
if (attach) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tmux attach exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return SESSION;
|
||||
return SESSION;
|
||||
}
|
||||
|
||||
async function killSession(name: string) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn("tmux", ["kill-session", "-t", name], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("exit", () => resolve());
|
||||
child.on("error", () => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn("tmux", ["kill-session", "-t", name], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("exit", () => resolve());
|
||||
child.on("error", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
+10
-10
@@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { waitForever } from "./wait.js";
|
||||
|
||||
describe("waitForever", () => {
|
||||
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
||||
const promise = waitForever();
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
1_000_000,
|
||||
);
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
||||
const promise = waitForever();
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
1_000_000,
|
||||
);
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
export function waitForever() {
|
||||
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
||||
const interval = setInterval(() => {}, 1_000_000);
|
||||
interval.unref();
|
||||
return new Promise<void>(() => {
|
||||
/* never resolve */
|
||||
});
|
||||
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
||||
const interval = setInterval(() => {}, 1_000_000);
|
||||
interval.unref();
|
||||
return new Promise<void>(() => {
|
||||
/* never resolve */
|
||||
});
|
||||
}
|
||||
|
||||
+126
-126
@@ -5,141 +5,141 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { sendCommand } from "./send.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const baseDeps = {
|
||||
assertProvider: vi.fn(),
|
||||
sendMessageWeb: vi.fn(),
|
||||
resolveTwilioMediaUrl: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
assertProvider: vi.fn(),
|
||||
sendMessageWeb: vi.fn(),
|
||||
resolveTwilioMediaUrl: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as unknown as CliDeps;
|
||||
|
||||
describe("sendCommand", () => {
|
||||
it("validates wait and poll", async () => {
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "-1",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||
it("validates wait and poll", async () => {
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "-1",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "0",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Poll must be > 0 seconds");
|
||||
});
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "0",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Poll must be > 0 seconds");
|
||||
});
|
||||
|
||||
it("handles web dry-run and warns on wait", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "5",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
dryRun: true,
|
||||
media: "pic.jpg",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
it("handles web dry-run and warns on wait", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "5",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
dryRun: true,
|
||||
media: "pic.jpg",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends via web and outputs JSON", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "1",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "web"'),
|
||||
);
|
||||
});
|
||||
it("sends via web and outputs JSON", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "1",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "web"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports twilio dry-run", async () => {
|
||||
const deps = { ...baseDeps } as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
dryRun: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
it("supports twilio dry-run", async () => {
|
||||
const deps = { ...baseDeps } as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
dryRun: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends via twilio with media and skips wait when zero", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
media: "pic.jpg",
|
||||
serveMedia: true,
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||
serveMedia: true,
|
||||
runtime,
|
||||
});
|
||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "twilio"'),
|
||||
);
|
||||
});
|
||||
it("sends via twilio with media and skips wait when zero", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
media: "pic.jpg",
|
||||
serveMedia: true,
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||
serveMedia: true,
|
||||
runtime,
|
||||
});
|
||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "twilio"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+99
-99
@@ -4,109 +4,109 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { Provider } from "../utils.js";
|
||||
|
||||
export async function sendCommand(
|
||||
opts: {
|
||||
to: string;
|
||||
message: string;
|
||||
wait: string;
|
||||
poll: string;
|
||||
provider: Provider;
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
media?: string;
|
||||
serveMedia?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: {
|
||||
to: string;
|
||||
message: string;
|
||||
wait: string;
|
||||
poll: string;
|
||||
provider: Provider;
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
media?: string;
|
||||
serveMedia?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
deps.assertProvider(opts.provider);
|
||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||
deps.assertProvider(opts.provider);
|
||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||
|
||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||
throw new Error("Wait must be >= 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||
throw new Error("Poll must be > 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||
throw new Error("Wait must be >= 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||
throw new Error("Poll must be > 0 seconds");
|
||||
}
|
||||
|
||||
if (opts.provider === "web") {
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (waitSeconds !== 0) {
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
const res = await deps
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
to: opts.to,
|
||||
messageId: res.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (opts.provider === "web") {
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (waitSeconds !== 0) {
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
const res = await deps
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
to: opts.to,
|
||||
messageId: res.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaUrl: string | undefined;
|
||||
if (opts.media) {
|
||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||
serveMedia: Boolean(opts.serveMedia),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
let mediaUrl: string | undefined;
|
||||
if (opts.media) {
|
||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||
serveMedia: Boolean(opts.serveMedia),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deps.sendMessage(
|
||||
opts.to,
|
||||
opts.message,
|
||||
{ mediaUrl },
|
||||
runtime,
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "twilio",
|
||||
to: opts.to,
|
||||
sid: result?.sid ?? null,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!result) return;
|
||||
if (waitSeconds === 0) return;
|
||||
await deps.waitForFinalStatus(
|
||||
result.client,
|
||||
result.sid,
|
||||
waitSeconds,
|
||||
pollSeconds,
|
||||
runtime,
|
||||
);
|
||||
const result = await deps.sendMessage(
|
||||
opts.to,
|
||||
opts.message,
|
||||
{ mediaUrl },
|
||||
runtime,
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "twilio",
|
||||
to: opts.to,
|
||||
sid: result?.sid ?? null,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!result) return;
|
||||
if (waitSeconds === 0) return;
|
||||
await deps.waitForFinalStatus(
|
||||
result.client,
|
||||
result.sid,
|
||||
waitSeconds,
|
||||
pollSeconds,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
+31
-31
@@ -5,46 +5,46 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { statusCommand } from "./status.js";
|
||||
|
||||
vi.mock("../twilio/messages.js", () => ({
|
||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||
}));
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const deps: CliDeps = {
|
||||
listRecentMessages: vi.fn(),
|
||||
listRecentMessages: vi.fn(),
|
||||
} as unknown as CliDeps;
|
||||
|
||||
describe("statusCommand", () => {
|
||||
it("validates limit and lookback", async () => {
|
||||
await expect(
|
||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||
).rejects.toThrow("limit must be between 1 and 200");
|
||||
await expect(
|
||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||
).rejects.toThrow("lookback must be > 0 minutes");
|
||||
});
|
||||
it("validates limit and lookback", async () => {
|
||||
await expect(
|
||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||
).rejects.toThrow("limit must be between 1 and 200");
|
||||
await expect(
|
||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||
).rejects.toThrow("lookback must be > 0 minutes");
|
||||
});
|
||||
|
||||
it("prints JSON when requested", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||
await statusCommand(
|
||||
{ limit: "5", lookback: "10", json: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify([{ sid: "1" }], null, 2),
|
||||
);
|
||||
});
|
||||
it("prints JSON when requested", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||
await statusCommand(
|
||||
{ limit: "5", lookback: "10", json: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify([{ sid: "1" }], null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||
});
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||
});
|
||||
});
|
||||
|
||||
+23
-23
@@ -3,29 +3,29 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { formatMessageLine } from "../twilio/messages.js";
|
||||
|
||||
export async function statusCommand(
|
||||
opts: { limit: string; lookback: string; json?: boolean },
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: { limit: string; lookback: string; json?: boolean },
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||
throw new Error("limit must be between 1 and 200");
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||
throw new Error("lookback must be > 0 minutes");
|
||||
}
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||
throw new Error("limit must be between 1 and 200");
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||
throw new Error("lookback must be > 0 minutes");
|
||||
}
|
||||
|
||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
runtime.log("No messages found in the requested window.");
|
||||
return;
|
||||
}
|
||||
for (const m of messages) {
|
||||
runtime.log(formatMessageLine(m));
|
||||
}
|
||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
runtime.log("No messages found in the requested window.");
|
||||
return;
|
||||
}
|
||||
for (const m of messages) {
|
||||
runtime.log(formatMessageLine(m));
|
||||
}
|
||||
}
|
||||
|
||||
+60
-60
@@ -5,72 +5,72 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { upCommand } from "./up.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const makeDeps = (): CliDeps => ({
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
readEnv: vi.fn().mockReturnValue({
|
||||
whatsappFrom: "whatsapp:+1555",
|
||||
whatsappSenderSid: "WW",
|
||||
}),
|
||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
readEnv: vi.fn().mockReturnValue({
|
||||
whatsappFrom: "whatsapp:+1555",
|
||||
whatsappSenderSid: "WW",
|
||||
}),
|
||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
describe("upCommand", () => {
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it("performs dry run and returns mock data", async () => {
|
||||
runtime.log.mockClear();
|
||||
const result = await upCommand(
|
||||
{ port: "42873", path: "/cb", dryRun: true },
|
||||
makeDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would enable funnel on port 42873",
|
||||
);
|
||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||
expect(result?.senderSid).toBeUndefined();
|
||||
});
|
||||
it("performs dry run and returns mock data", async () => {
|
||||
runtime.log.mockClear();
|
||||
const result = await upCommand(
|
||||
{ port: "42873", path: "/cb", dryRun: true },
|
||||
makeDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would enable funnel on port 42873",
|
||||
);
|
||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||
expect(result?.senderSid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||
const deps = makeDeps();
|
||||
const res = await upCommand(
|
||||
{ port: "42873", path: "/hook", verbose: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||
"tailscale",
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||
expect(deps.startWebhook).toHaveBeenCalled();
|
||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"SID123",
|
||||
"https://tailnet-host/hook",
|
||||
"POST",
|
||||
runtime,
|
||||
);
|
||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||
// waiter is returned to keep the process alive in real use.
|
||||
expect(typeof res?.waiter).toBe("function");
|
||||
});
|
||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||
const deps = makeDeps();
|
||||
const res = await upCommand(
|
||||
{ port: "42873", path: "/hook", verbose: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||
"tailscale",
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||
expect(deps.startWebhook).toHaveBeenCalled();
|
||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"SID123",
|
||||
"https://tailnet-host/hook",
|
||||
"POST",
|
||||
runtime,
|
||||
);
|
||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||
// waiter is returned to keep the process alive in real use.
|
||||
expect(typeof res?.waiter).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
+55
-55
@@ -4,65 +4,65 @@ import { retryAsync } from "../infra/retry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export async function upCommand(
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
await deps.ensurePortAvailable(port);
|
||||
const env = deps.readEnv(runtime);
|
||||
if (opts.dryRun) {
|
||||
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
||||
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
||||
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
||||
const publicUrl = `https://dry-run${opts.path}`;
|
||||
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
||||
}
|
||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||
const host = await deps.getTailnetHostname();
|
||||
const publicUrl = `https://${host}${opts.path}`;
|
||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||
await deps.ensurePortAvailable(port);
|
||||
const env = deps.readEnv(runtime);
|
||||
if (opts.dryRun) {
|
||||
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
||||
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
||||
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
||||
const publicUrl = `https://dry-run${opts.path}`;
|
||||
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
||||
}
|
||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||
const host = await deps.getTailnetHostname();
|
||||
const publicUrl = `https://${host}${opts.path}`;
|
||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
undefined,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
undefined,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
|
||||
if (!deps.createClient) {
|
||||
throw new Error("Twilio client dependency missing");
|
||||
}
|
||||
const twilioClient = deps.createClient(env);
|
||||
const senderSid = await deps.findWhatsappSenderSid(
|
||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||
env.whatsappFrom,
|
||||
env.whatsappSenderSid,
|
||||
runtime,
|
||||
);
|
||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||
if (!deps.createClient) {
|
||||
throw new Error("Twilio client dependency missing");
|
||||
}
|
||||
const twilioClient = deps.createClient(env);
|
||||
const senderSid = await deps.findWhatsappSenderSid(
|
||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||
env.whatsappFrom,
|
||||
env.whatsappSenderSid,
|
||||
runtime,
|
||||
);
|
||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||
|
||||
runtime.log(
|
||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||
);
|
||||
runtime.log(
|
||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||
);
|
||||
|
||||
return { server, publicUrl, senderSid, waiter };
|
||||
return { server, publicUrl, senderSid, waiter };
|
||||
}
|
||||
|
||||
@@ -6,57 +6,57 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { webhookCommand } from "./webhook.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const deps: CliDeps = {
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
};
|
||||
|
||||
describe("webhookCommand", () => {
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it("logs dry run instead of starting server", async () => {
|
||||
runtime.log.mockClear();
|
||||
const res = await webhookCommand(
|
||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would start webhook on port 42873 path /hook",
|
||||
);
|
||||
});
|
||||
it("logs dry run instead of starting server", async () => {
|
||||
runtime.log.mockClear();
|
||||
const res = await webhookCommand(
|
||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would start webhook on port 42873 path /hook",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts webhook when valid", async () => {
|
||||
const res = await webhookCommand(
|
||||
{
|
||||
port: "42873",
|
||||
path: "/hook",
|
||||
reply: "ok",
|
||||
verbose: true,
|
||||
ingress: "none",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||
42873,
|
||||
"/hook",
|
||||
"ok",
|
||||
true,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toEqual({ server: true });
|
||||
});
|
||||
it("starts webhook when valid", async () => {
|
||||
const res = await webhookCommand(
|
||||
{
|
||||
port: "42873",
|
||||
path: "/hook",
|
||||
reply: "ok",
|
||||
verbose: true,
|
||||
ingress: "none",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||
42873,
|
||||
"/hook",
|
||||
"ok",
|
||||
true,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toEqual({ server: true });
|
||||
});
|
||||
});
|
||||
|
||||
+52
-52
@@ -4,60 +4,60 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { upCommand } from "./up.js";
|
||||
|
||||
export async function webhookCommand(
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
reply?: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
ingress?: "tailscale" | "none";
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
reply?: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
ingress?: "tailscale" | "none";
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
const ingress = opts.ingress ?? "tailscale";
|
||||
const ingress = opts.ingress ?? "tailscale";
|
||||
|
||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||
if (ingress === "tailscale") {
|
||||
const result = await upCommand(
|
||||
{
|
||||
port: opts.port,
|
||||
path: opts.path,
|
||||
verbose: opts.verbose,
|
||||
yes: opts.yes,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
return result.server;
|
||||
}
|
||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||
if (ingress === "tailscale") {
|
||||
const result = await upCommand(
|
||||
{
|
||||
port: opts.port,
|
||||
path: opts.path,
|
||||
verbose: opts.verbose,
|
||||
yes: opts.yes,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
return result.server;
|
||||
}
|
||||
|
||||
// Local-only webhook (no ingress / no Twilio update).
|
||||
await deps.ensurePortAvailable(port);
|
||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
opts.reply,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
return server;
|
||||
// Local-only webhook (no ingress / no Twilio update).
|
||||
await deps.ensurePortAvailable(port);
|
||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
opts.reply,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
+161
-83
@@ -10,100 +10,178 @@ export type ClaudeOutputFormat = "text" | "json" | "stream-json";
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
export type SessionConfig = {
|
||||
scope?: SessionScope;
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
store?: string;
|
||||
sessionArgNew?: string[];
|
||||
sessionArgResume?: string[];
|
||||
sessionArgBeforeBody?: boolean;
|
||||
scope?: SessionScope;
|
||||
resetTriggers?: string[];
|
||||
idleMinutes?: number;
|
||||
heartbeatIdleMinutes?: number;
|
||||
store?: string;
|
||||
sessionArgNew?: string[];
|
||||
sessionArgResume?: string[];
|
||||
sessionArgBeforeBody?: boolean;
|
||||
sendSystemOnce?: boolean;
|
||||
sessionIntro?: string;
|
||||
typingIntervalSeconds?: number;
|
||||
heartbeatMinutes?: number;
|
||||
};
|
||||
|
||||
export type LoggingConfig = {
|
||||
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||
file?: string;
|
||||
};
|
||||
|
||||
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 = {
|
||||
inbound?: {
|
||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||
reply?: {
|
||||
mode: ReplyMode;
|
||||
text?: string; // for mode=text, can contain {{Body}}
|
||||
command?: string[]; // for mode=command, argv with templates
|
||||
template?: string; // prepend template string when building command/prompt
|
||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
||||
mediaUrl?: string; // optional media attachment (path or URL)
|
||||
session?: SessionConfig;
|
||||
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
||||
};
|
||||
};
|
||||
logging?: LoggingConfig;
|
||||
inbound?: {
|
||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||
transcribeAudio?: {
|
||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||
command: string[];
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
reply?: {
|
||||
mode: ReplyMode;
|
||||
text?: string; // for mode=text, can contain {{Body}}
|
||||
command?: string[]; // for mode=command, argv with templates
|
||||
cwd?: string; // working directory for command execution
|
||||
template?: string; // prepend template string when building command/prompt
|
||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
||||
mediaUrl?: string; // optional media attachment (path or URL)
|
||||
session?: SessionConfig;
|
||||
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
||||
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
|
||||
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
|
||||
heartbeatMinutes?: number; // auto-ping cadence for command mode
|
||||
};
|
||||
};
|
||||
web?: WebConfig;
|
||||
};
|
||||
|
||||
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
||||
|
||||
const ReplySchema = z
|
||||
.object({
|
||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||
text: z.string().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
template: z.string().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
bodyPrefix: z.string().optional(),
|
||||
mediaUrl: z.string().optional(),
|
||||
session: z
|
||||
.object({
|
||||
scope: z
|
||||
.union([z.literal("per-sender"), z.literal("global")])
|
||||
.optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
store: z.string().optional(),
|
||||
sessionArgNew: z.array(z.string()).optional(),
|
||||
sessionArgResume: z.array(z.string()).optional(),
|
||||
sessionArgBeforeBody: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
claudeOutputFormat: z
|
||||
.union([
|
||||
z.literal("text"),
|
||||
z.literal("json"),
|
||||
z.literal("stream-json"),
|
||||
z.undefined(),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
||||
{
|
||||
message:
|
||||
"reply.text is required for mode=text; reply.command is required for mode=command",
|
||||
},
|
||||
);
|
||||
.object({
|
||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||
text: z.string().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
cwd: z.string().optional(),
|
||||
template: z.string().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
bodyPrefix: z.string().optional(),
|
||||
mediaUrl: z.string().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
session: z
|
||||
.object({
|
||||
scope: z
|
||||
.union([z.literal("per-sender"), z.literal("global")])
|
||||
.optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||
store: z.string().optional(),
|
||||
sessionArgNew: z.array(z.string()).optional(),
|
||||
sessionArgResume: z.array(z.string()).optional(),
|
||||
sessionArgBeforeBody: z.boolean().optional(),
|
||||
sendSystemOnce: z.boolean().optional(),
|
||||
sessionIntro: z.string().optional(),
|
||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
heartbeatMinutes: z.number().int().nonnegative().optional(),
|
||||
claudeOutputFormat: z
|
||||
.union([
|
||||
z.literal("text"),
|
||||
z.literal("json"),
|
||||
z.literal("stream-json"),
|
||||
z.undefined(),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
||||
{
|
||||
message:
|
||||
"reply.text is required for mode=text; reply.command is required for mode=command",
|
||||
},
|
||||
);
|
||||
|
||||
const WarelaySchema = z.object({
|
||||
inbound: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
reply: ReplySchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
logging: z
|
||||
.object({
|
||||
level: z
|
||||
.union([
|
||||
z.literal("silent"),
|
||||
z.literal("fatal"),
|
||||
z.literal("error"),
|
||||
z.literal("warn"),
|
||||
z.literal("info"),
|
||||
z.literal("debug"),
|
||||
z.literal("trace"),
|
||||
])
|
||||
.optional(),
|
||||
file: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
inbound: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
transcribeAudio: z
|
||||
.object({
|
||||
command: z.array(z.string()),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
reply: ReplySchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
web: z
|
||||
.object({
|
||||
heartbeatSeconds: z.number().int().positive().optional(),
|
||||
reconnect: z
|
||||
.object({
|
||||
initialMs: z.number().positive().optional(),
|
||||
maxMs: z.number().positive().optional(),
|
||||
factor: z.number().positive().optional(),
|
||||
jitter: z.number().min(0).max(1).optional(),
|
||||
maxAttempts: z.number().int().min(0).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function loadConfig(): WarelayConfig {
|
||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const validated = WarelaySchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
console.error("Invalid warelay config:");
|
||||
for (const iss of validated.error.issues) {
|
||||
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return validated.data as WarelayConfig;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
||||
return {};
|
||||
}
|
||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const validated = WarelaySchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
console.error("Invalid warelay config:");
|
||||
for (const iss of validated.error.issues) {
|
||||
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return validated.data as WarelayConfig;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest";
|
||||
import { deriveSessionKey } from "./sessions.js";
|
||||
|
||||
describe("sessions", () => {
|
||||
it("returns normalized per-sender key", () => {
|
||||
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
||||
"+1555",
|
||||
);
|
||||
});
|
||||
it("returns normalized per-sender key", () => {
|
||||
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
||||
"+1555",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to unknown when sender missing", () => {
|
||||
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
||||
});
|
||||
it("falls back to unknown when sender missing", () => {
|
||||
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
||||
});
|
||||
|
||||
it("global scope returns global", () => {
|
||||
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
||||
});
|
||||
it("global scope returns global", () => {
|
||||
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
||||
});
|
||||
});
|
||||
|
||||
+31
-27
@@ -8,49 +8,53 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
export type SessionEntry = { sessionId: string; updatedAt: number };
|
||||
export type SessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
systemSent?: boolean;
|
||||
};
|
||||
|
||||
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||
export const DEFAULT_IDLE_MINUTES = 60;
|
||||
|
||||
export function resolveStorePath(store?: string) {
|
||||
if (!store) return SESSION_STORE_DEFAULT;
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace("~", os.homedir()));
|
||||
return path.resolve(store);
|
||||
if (!store) return SESSION_STORE_DEFAULT;
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace("~", os.homedir()));
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
storePath: string,
|
||||
): Record<string, SessionEntry> {
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as Record<string, SessionEntry>;
|
||||
}
|
||||
} catch {
|
||||
// ignore missing/invalid store; we'll recreate it
|
||||
}
|
||||
return {};
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as Record<string, SessionEntry>;
|
||||
}
|
||||
} catch {
|
||||
// ignore missing/invalid store; we'll recreate it
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function saveSessionStore(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
) {
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(store, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(store, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
// Decide which session bucket to use (per-sender vs global).
|
||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||
if (scope === "global") return "global";
|
||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||
return from || "unknown";
|
||||
if (scope === "global") return "global";
|
||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||
return from || "unknown";
|
||||
}
|
||||
|
||||
+80
-80
@@ -4,94 +4,94 @@ import { ensureTwilioEnv, readEnv } from "./env.js";
|
||||
import type { RuntimeEnv } from "./runtime.js";
|
||||
|
||||
const baseEnv = {
|
||||
TWILIO_ACCOUNT_SID: "AC123",
|
||||
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
||||
TWILIO_ACCOUNT_SID: "AC123",
|
||||
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
||||
};
|
||||
|
||||
describe("env helpers", () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = {};
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = {};
|
||||
});
|
||||
|
||||
function setEnv(vars: Record<string, string | undefined>) {
|
||||
process.env = {};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
function setEnv(vars: Record<string, string | undefined>) {
|
||||
process.env = {};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
it("reads env with auth token", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
expect(cfg.accountSid).toBe("AC123");
|
||||
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
||||
if ("authToken" in cfg.auth) {
|
||||
expect(cfg.auth.authToken).toBe("token");
|
||||
} else {
|
||||
throw new Error("Expected auth token");
|
||||
}
|
||||
});
|
||||
it("reads env with auth token", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
expect(cfg.accountSid).toBe("AC123");
|
||||
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
||||
if ("authToken" in cfg.auth) {
|
||||
expect(cfg.auth.authToken).toBe("token");
|
||||
} else {
|
||||
throw new Error("Expected auth token");
|
||||
}
|
||||
});
|
||||
|
||||
it("reads env with API key/secret", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: "key",
|
||||
TWILIO_API_SECRET: "secret",
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
||||
expect(cfg.auth.apiKey).toBe("key");
|
||||
expect(cfg.auth.apiSecret).toBe("secret");
|
||||
} else {
|
||||
throw new Error("Expected API key/secret");
|
||||
}
|
||||
});
|
||||
it("reads env with API key/secret", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: "key",
|
||||
TWILIO_API_SECRET: "secret",
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
||||
expect(cfg.auth.apiKey).toBe("key");
|
||||
expect(cfg.auth.apiSecret).toBe("secret");
|
||||
} else {
|
||||
throw new Error("Expected API key/secret");
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast on invalid env", () => {
|
||||
setEnv({
|
||||
TWILIO_ACCOUNT_SID: "",
|
||||
TWILIO_WHATSAPP_FROM: "",
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => readEnv(runtime)).toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
it("fails fast on invalid env", () => {
|
||||
setEnv({
|
||||
TWILIO_ACCOUNT_SID: "",
|
||||
TWILIO_WHATSAPP_FROM: "",
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => readEnv(runtime)).toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ensureTwilioEnv passes when token present", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
||||
});
|
||||
it("ensureTwilioEnv passes when token present", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
||||
});
|
||||
|
||||
it("ensureTwilioEnv fails when missing auth", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
||||
});
|
||||
it("ensureTwilioEnv fails when missing auth", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
||||
});
|
||||
});
|
||||
|
||||
+86
-86
@@ -4,103 +4,103 @@ import { danger } from "./globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||
|
||||
export type AuthMode =
|
||||
| { accountSid: string; authToken: string }
|
||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
||||
| { accountSid: string; authToken: string }
|
||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
||||
|
||||
export type EnvConfig = {
|
||||
accountSid: string;
|
||||
whatsappFrom: string;
|
||||
whatsappSenderSid?: string;
|
||||
auth: AuthMode;
|
||||
accountSid: string;
|
||||
whatsappFrom: string;
|
||||
whatsappSenderSid?: string;
|
||||
auth: AuthMode;
|
||||
};
|
||||
|
||||
const EnvSchema = z
|
||||
.object({
|
||||
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
||||
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
||||
TWILIO_SENDER_SID: z.string().optional(),
|
||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||
TWILIO_API_KEY: z.string().optional(),
|
||||
TWILIO_API_SECRET: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
||||
});
|
||||
}
|
||||
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!val.TWILIO_AUTH_TOKEN &&
|
||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
||||
});
|
||||
}
|
||||
});
|
||||
.object({
|
||||
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
||||
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
||||
TWILIO_SENDER_SID: z.string().optional(),
|
||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||
TWILIO_API_KEY: z.string().optional(),
|
||||
TWILIO_API_SECRET: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
||||
});
|
||||
}
|
||||
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!val.TWILIO_AUTH_TOKEN &&
|
||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
||||
// Load and validate Twilio auth + sender configuration from env.
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
runtime.error("Invalid environment configuration:");
|
||||
parsed.error.issues.forEach((iss) => {
|
||||
runtime.error(`- ${iss.message}`);
|
||||
});
|
||||
runtime.exit(1);
|
||||
}
|
||||
// Load and validate Twilio auth + sender configuration from env.
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
runtime.error("Invalid environment configuration:");
|
||||
parsed.error.issues.forEach((iss) => {
|
||||
runtime.error(`- ${iss.message}`);
|
||||
});
|
||||
runtime.exit(1);
|
||||
}
|
||||
|
||||
const {
|
||||
TWILIO_ACCOUNT_SID: accountSid,
|
||||
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
||||
TWILIO_SENDER_SID: whatsappSenderSid,
|
||||
TWILIO_AUTH_TOKEN: authToken,
|
||||
TWILIO_API_KEY: apiKey,
|
||||
TWILIO_API_SECRET: apiSecret,
|
||||
} = parsed.data;
|
||||
const {
|
||||
TWILIO_ACCOUNT_SID: accountSid,
|
||||
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
||||
TWILIO_SENDER_SID: whatsappSenderSid,
|
||||
TWILIO_AUTH_TOKEN: authToken,
|
||||
TWILIO_API_KEY: apiKey,
|
||||
TWILIO_API_SECRET: apiSecret,
|
||||
} = parsed.data;
|
||||
|
||||
let auth: AuthMode;
|
||||
if (apiKey && apiSecret) {
|
||||
auth = { accountSid, apiKey, apiSecret };
|
||||
} else if (authToken) {
|
||||
auth = { accountSid, authToken };
|
||||
} else {
|
||||
runtime.error("Missing Twilio auth configuration");
|
||||
runtime.exit(1);
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
let auth: AuthMode;
|
||||
if (apiKey && apiSecret) {
|
||||
auth = { accountSid, apiKey, apiSecret };
|
||||
} else if (authToken) {
|
||||
auth = { accountSid, authToken };
|
||||
} else {
|
||||
runtime.error("Missing Twilio auth configuration");
|
||||
runtime.exit(1);
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
return {
|
||||
accountSid,
|
||||
whatsappFrom,
|
||||
whatsappSenderSid,
|
||||
auth,
|
||||
};
|
||||
return {
|
||||
accountSid,
|
||||
whatsappFrom,
|
||||
whatsappSenderSid,
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||
const missing = required.filter((k) => !process.env[k]);
|
||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||
const hasKey = Boolean(
|
||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||
);
|
||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||
runtime.error(
|
||||
danger(
|
||||
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||
const missing = required.filter((k) => !process.env[k]);
|
||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||
const hasKey = Boolean(
|
||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||
);
|
||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||
runtime.error(
|
||||
danger(
|
||||
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-21
@@ -2,28 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";
|
||||
|
||||
describe("globals", () => {
|
||||
afterEach(() => {
|
||||
setVerbose(false);
|
||||
setYes(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
setVerbose(false);
|
||||
setYes(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("toggles verbose flag and logs when enabled", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
setVerbose(false);
|
||||
logVerbose("hidden");
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
it("toggles verbose flag and logs when enabled", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
setVerbose(false);
|
||||
logVerbose("hidden");
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
|
||||
setVerbose(true);
|
||||
logVerbose("shown");
|
||||
expect(isVerbose()).toBe(true);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
||||
});
|
||||
setVerbose(true);
|
||||
logVerbose("shown");
|
||||
expect(isVerbose()).toBe(true);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
||||
});
|
||||
|
||||
it("stores yes flag", () => {
|
||||
setYes(true);
|
||||
expect(isYes()).toBe(true);
|
||||
setYes(false);
|
||||
expect(isYes()).toBe(false);
|
||||
});
|
||||
it("stores yes flag", () => {
|
||||
setYes(true);
|
||||
expect(isYes()).toBe(true);
|
||||
setYes(false);
|
||||
expect(isYes()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -4,23 +4,23 @@ let globalVerbose = false;
|
||||
let globalYes = false;
|
||||
|
||||
export function setVerbose(v: boolean) {
|
||||
globalVerbose = v;
|
||||
globalVerbose = v;
|
||||
}
|
||||
|
||||
export function isVerbose() {
|
||||
return globalVerbose;
|
||||
return globalVerbose;
|
||||
}
|
||||
|
||||
export function logVerbose(message: string) {
|
||||
if (globalVerbose) console.log(chalk.gray(message));
|
||||
if (globalVerbose) console.log(chalk.gray(message));
|
||||
}
|
||||
|
||||
export function setYes(v: boolean) {
|
||||
globalYes = v;
|
||||
globalYes = v;
|
||||
}
|
||||
|
||||
export function isYes() {
|
||||
return globalYes;
|
||||
return globalYes;
|
||||
}
|
||||
|
||||
export const success = chalk.green;
|
||||
|
||||
+112
-112
@@ -6,134 +6,134 @@ import * as providerWeb from "./provider-web.js";
|
||||
import { defaultRuntime } from "./runtime.js";
|
||||
|
||||
vi.mock("twilio", () => {
|
||||
const { factory } = createMockTwilio();
|
||||
return { default: factory };
|
||||
const { factory } = createMockTwilio();
|
||||
return { default: factory };
|
||||
});
|
||||
|
||||
import * as index from "./index.js";
|
||||
import * as provider from "./provider-web.js";
|
||||
|
||||
beforeEach(() => {
|
||||
index.program.exitOverride();
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||
vi.clearAllMocks();
|
||||
index.program.exitOverride();
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CLI commands", () => {
|
||||
it("exposes login command", () => {
|
||||
const names = index.program.commands.map((c) => c.name());
|
||||
expect(names).toContain("login");
|
||||
});
|
||||
it("exposes login command", () => {
|
||||
const names = index.program.commands.map((c) => c.name());
|
||||
expect(names).toContain("login");
|
||||
});
|
||||
|
||||
it("send command routes to web provider", async () => {
|
||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
[
|
||||
"send",
|
||||
"--to",
|
||||
"+1555",
|
||||
"--message",
|
||||
"hi",
|
||||
"--provider",
|
||||
"web",
|
||||
"--wait",
|
||||
"0",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(sendWeb).toHaveBeenCalled();
|
||||
});
|
||||
it("send command routes to web provider", async () => {
|
||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
[
|
||||
"send",
|
||||
"--to",
|
||||
"+1555",
|
||||
"--message",
|
||||
"hi",
|
||||
"--provider",
|
||||
"web",
|
||||
"--wait",
|
||||
"0",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(sendWeb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command uses twilio path when provider=twilio", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
it("send command uses twilio path when provider=twilio", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command supports dry-run and skips sending", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
it("send command supports dry-run and skips sending", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command outputs JSON when requested", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
||||
const logSpy = vi.spyOn(defaultRuntime, "log");
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"sid": "SMJSON"'),
|
||||
);
|
||||
});
|
||||
it("send command outputs JSON when requested", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
||||
const logSpy = vi.spyOn(defaultRuntime, "log");
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"sid": "SMJSON"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("login command calls web login", async () => {
|
||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(["login"], { from: "user" });
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
it("login command calls web login", async () => {
|
||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(["login"], { from: "user" });
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("status command prints JSON", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.list
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "1",
|
||||
status: "delivered",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||
from: "a",
|
||||
to: "b",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "2",
|
||||
status: "sent",
|
||||
direction: "outbound-api",
|
||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||
from: "b",
|
||||
to: "a",
|
||||
body: "yo",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]);
|
||||
const runtime = {
|
||||
...defaultRuntime,
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await statusCommand(
|
||||
{ limit: "1", lookback: "10", json: true },
|
||||
createDefaultDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
it("status command prints JSON", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.list
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "1",
|
||||
status: "delivered",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||
from: "a",
|
||||
to: "b",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "2",
|
||||
status: "sent",
|
||||
direction: "outbound-api",
|
||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||
from: "b",
|
||||
to: "a",
|
||||
body: "yo",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]);
|
||||
const runtime = {
|
||||
...defaultRuntime,
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await statusCommand(
|
||||
{ limit: "1", lookback: "10", json: true },
|
||||
createDefaultDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+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";
|
||||
|
||||
describe("normalizeE164", () => {
|
||||
it("strips whatsapp prefix and whitespace", () => {
|
||||
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
||||
});
|
||||
it("strips whatsapp prefix and whitespace", () => {
|
||||
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("adds plus when missing", () => {
|
||||
expect(normalizeE164("1555123")).toBe("+1555123");
|
||||
});
|
||||
it("adds plus when missing", () => {
|
||||
expect(normalizeE164("1555123")).toBe("+1555123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toWhatsappJid", () => {
|
||||
it("converts E164 to jid", () => {
|
||||
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
||||
});
|
||||
it("converts E164 to jid", () => {
|
||||
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertProvider", () => {
|
||||
it("accepts valid providers", () => {
|
||||
expect(() => assertProvider("twilio")).not.toThrow();
|
||||
expect(() => assertProvider("web")).not.toThrow();
|
||||
});
|
||||
it("accepts valid providers", () => {
|
||||
expect(() => assertProvider("twilio")).not.toThrow();
|
||||
expect(() => assertProvider("web")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on invalid provider", () => {
|
||||
expect(() => assertProvider("invalid" as string)).toThrow();
|
||||
});
|
||||
it("throws on invalid provider", () => {
|
||||
expect(() => assertProvider("invalid" as string)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+87
-69
@@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import {
|
||||
autoReplyIfConfigured,
|
||||
getReplyFromConfig,
|
||||
autoReplyIfConfigured,
|
||||
getReplyFromConfig,
|
||||
} from "./auto-reply/reply.js";
|
||||
import { applyTemplate } from "./auto-reply/templating.js";
|
||||
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
|
||||
@@ -13,42 +13,42 @@ import { promptYesNo } from "./cli/prompt.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { loadConfig } from "./config/config.js";
|
||||
import {
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "./config/sessions.js";
|
||||
import { readEnv } from "./env.js";
|
||||
import { ensureBinary } from "./infra/binaries.js";
|
||||
import {
|
||||
describePortOwner,
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
describePortOwner,
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
} from "./infra/ports.js";
|
||||
import {
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
} from "./infra/tailscale.js";
|
||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||
import { monitorWebProvider } from "./provider-web.js";
|
||||
import { createClient } from "./twilio/client.js";
|
||||
import {
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
sortByDateDesc,
|
||||
uniqueBySid,
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
sortByDateDesc,
|
||||
uniqueBySid,
|
||||
} from "./twilio/messages.js";
|
||||
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
||||
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
||||
import { sendTypingIndicator } from "./twilio/typing.js";
|
||||
import {
|
||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||
updateWebhook as updateWebhookImpl,
|
||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||
updateWebhook as updateWebhookImpl,
|
||||
} from "./twilio/update-webhook.js";
|
||||
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
||||
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
||||
@@ -66,56 +66,74 @@ const setMessagingServiceWebhook = setMessagingServiceWebhookImpl;
|
||||
const updateWebhook = updateWebhookImpl;
|
||||
|
||||
export {
|
||||
assertProvider,
|
||||
autoReplyIfConfigured,
|
||||
applyTemplate,
|
||||
createClient,
|
||||
deriveSessionKey,
|
||||
describePortOwner,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensurePortAvailable,
|
||||
ensureTailscaledInstalled,
|
||||
findIncomingNumberSidImpl as findIncomingNumberSid,
|
||||
findMessagingServiceSidImpl as findMessagingServiceSid,
|
||||
findWhatsappSenderSid,
|
||||
formatMessageLine,
|
||||
formatTwilioError,
|
||||
getReplyFromConfig,
|
||||
getTailnetHostname,
|
||||
handlePortError,
|
||||
logTwilioSendError,
|
||||
listRecentMessages,
|
||||
loadConfig,
|
||||
loadSessionStore,
|
||||
monitorTwilio,
|
||||
monitorWebProvider,
|
||||
normalizeE164,
|
||||
PortInUseError,
|
||||
promptYesNo,
|
||||
createDefaultDeps,
|
||||
readEnv,
|
||||
resolveStorePath,
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
saveSessionStore,
|
||||
sendMessage,
|
||||
sendTypingIndicator,
|
||||
setMessagingServiceWebhook,
|
||||
sortByDateDesc,
|
||||
startWebhook,
|
||||
updateWebhook,
|
||||
uniqueBySid,
|
||||
waitForFinalStatus,
|
||||
waitForever,
|
||||
toWhatsappJid,
|
||||
program,
|
||||
assertProvider,
|
||||
autoReplyIfConfigured,
|
||||
applyTemplate,
|
||||
createClient,
|
||||
deriveSessionKey,
|
||||
describePortOwner,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensurePortAvailable,
|
||||
ensureTailscaledInstalled,
|
||||
findIncomingNumberSidImpl as findIncomingNumberSid,
|
||||
findMessagingServiceSidImpl as findMessagingServiceSid,
|
||||
findWhatsappSenderSid,
|
||||
formatMessageLine,
|
||||
formatTwilioError,
|
||||
getReplyFromConfig,
|
||||
getTailnetHostname,
|
||||
handlePortError,
|
||||
logTwilioSendError,
|
||||
listRecentMessages,
|
||||
loadConfig,
|
||||
loadSessionStore,
|
||||
monitorTwilio,
|
||||
monitorWebProvider,
|
||||
normalizeE164,
|
||||
PortInUseError,
|
||||
promptYesNo,
|
||||
createDefaultDeps,
|
||||
readEnv,
|
||||
resolveStorePath,
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
saveSessionStore,
|
||||
sendMessage,
|
||||
sendTypingIndicator,
|
||||
setMessagingServiceWebhook,
|
||||
sortByDateDesc,
|
||||
startWebhook,
|
||||
updateWebhook,
|
||||
uniqueBySid,
|
||||
waitForFinalStatus,
|
||||
waitForever,
|
||||
toWhatsappJid,
|
||||
program,
|
||||
};
|
||||
|
||||
const isMain =
|
||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
|
||||
if (isMain) {
|
||||
program.parseAsync(process.argv);
|
||||
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
|
||||
// These log the error and exit gracefully instead of crashing without trace.
|
||||
process.on("unhandledRejection", (reason, _promise) => {
|
||||
console.error(
|
||||
"[warelay] Unhandled promise rejection:",
|
||||
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error(
|
||||
"[warelay] Uncaught exception:",
|
||||
error.stack ?? error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv);
|
||||
}
|
||||
|
||||
+29
-29
@@ -5,34 +5,34 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { ensureBinary } from "./binaries.js";
|
||||
|
||||
describe("ensureBinary", () => {
|
||||
it("passes through when binary exists", async () => {
|
||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
await ensureBinary("node", exec, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||
});
|
||||
it("passes through when binary exists", async () => {
|
||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
await ensureBinary("node", exec, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||
});
|
||||
|
||||
it("logs and exits when missing", async () => {
|
||||
const exec: typeof runExec = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("missing"));
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
});
|
||||
await expect(
|
||||
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"Missing required binary: ghost. Please install it.",
|
||||
);
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
it("logs and exits when missing", async () => {
|
||||
const exec: typeof runExec = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("missing"));
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
});
|
||||
await expect(
|
||||
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"Missing required binary: ghost. Please install it.",
|
||||
);
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@ import { runExec } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export async function ensureBinary(
|
||||
name: string,
|
||||
exec: typeof runExec = runExec,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
name: string,
|
||||
exec: typeof runExec = runExec,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<void> {
|
||||
// Abort early if a required CLI tool is missing.
|
||||
await exec("which", [name]).catch(() => {
|
||||
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
||||
runtime.exit(1);
|
||||
});
|
||||
// Abort early if a required CLI tool is missing.
|
||||
await exec("which", [name]).catch(() => {
|
||||
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
||||
runtime.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
+27
-27
@@ -2,35 +2,35 @@ import net from "node:net";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
} from "./ports.js";
|
||||
|
||||
describe("ports helpers", () => {
|
||||
it("ensurePortAvailable rejects when port busy", async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||
PortInUseError,
|
||||
);
|
||||
server.close();
|
||||
});
|
||||
it("ensurePortAvailable rejects when port busy", async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||
PortInUseError,
|
||||
);
|
||||
server.close();
|
||||
});
|
||||
|
||||
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: vi.fn() as unknown as (code: number) => never,
|
||||
};
|
||||
await handlePortError(
|
||||
{ code: "EADDRINUSE" },
|
||||
1234,
|
||||
"context",
|
||||
runtime,
|
||||
).catch(() => {});
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: vi.fn() as unknown as (code: number) => never,
|
||||
};
|
||||
await handlePortError(
|
||||
{ code: "EADDRINUSE" },
|
||||
1234,
|
||||
"context",
|
||||
runtime,
|
||||
).catch(() => {});
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
+82
-82
@@ -5,103 +5,103 @@ import { runExec } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
class PortInUseError extends Error {
|
||||
port: number;
|
||||
details?: string;
|
||||
port: number;
|
||||
details?: string;
|
||||
|
||||
constructor(port: number, details?: string) {
|
||||
super(`Port ${port} is already in use.`);
|
||||
this.name = "PortInUseError";
|
||||
this.port = port;
|
||||
this.details = details;
|
||||
}
|
||||
constructor(port: number, details?: string) {
|
||||
super(`Port ${port} is already in use.`);
|
||||
this.name = "PortInUseError";
|
||||
this.port = port;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
}
|
||||
|
||||
export async function describePortOwner(
|
||||
port: number,
|
||||
port: number,
|
||||
): Promise<string | undefined> {
|
||||
// Best-effort process info for a listening port (macOS/Linux).
|
||||
try {
|
||||
const { stdout } = await runExec("lsof", [
|
||||
"-i",
|
||||
`tcp:${port}`,
|
||||
"-sTCP:LISTEN",
|
||||
"-nP",
|
||||
]);
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed) return trimmed;
|
||||
} catch (err) {
|
||||
logVerbose(`lsof unavailable: ${String(err)}`);
|
||||
}
|
||||
return undefined;
|
||||
// Best-effort process info for a listening port (macOS/Linux).
|
||||
try {
|
||||
const { stdout } = await runExec("lsof", [
|
||||
"-i",
|
||||
`tcp:${port}`,
|
||||
"-sTCP:LISTEN",
|
||||
"-nP",
|
||||
]);
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed) return trimmed;
|
||||
} catch (err) {
|
||||
logVerbose(`lsof unavailable: ${String(err)}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function ensurePortAvailable(port: number): Promise<void> {
|
||||
// Detect EADDRINUSE early with a friendly message.
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once("error", (err) => reject(err))
|
||||
.once("listening", () => {
|
||||
tester.close(() => resolve());
|
||||
})
|
||||
.listen(port);
|
||||
});
|
||||
} catch (err) {
|
||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||
const details = await describePortOwner(port);
|
||||
throw new PortInUseError(port, details);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Detect EADDRINUSE early with a friendly message.
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once("error", (err) => reject(err))
|
||||
.once("listening", () => {
|
||||
tester.close(() => resolve());
|
||||
})
|
||||
.listen(port);
|
||||
});
|
||||
} catch (err) {
|
||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||
const details = await describePortOwner(port);
|
||||
throw new PortInUseError(port, details);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePortError(
|
||||
err: unknown,
|
||||
port: number,
|
||||
context: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
err: unknown,
|
||||
port: number,
|
||||
context: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<never> {
|
||||
// Uniform messaging for EADDRINUSE with optional owner details.
|
||||
if (
|
||||
err instanceof PortInUseError ||
|
||||
(isErrno(err) && err.code === "EADDRINUSE")
|
||||
) {
|
||||
const details =
|
||||
err instanceof PortInUseError
|
||||
? err.details
|
||||
: await describePortOwner(port);
|
||||
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
||||
if (details) {
|
||||
runtime.error(info("Port listener details:"));
|
||||
runtime.error(details);
|
||||
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
||||
runtime.error(
|
||||
warn(
|
||||
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
runtime.error(danger(`${context} failed: ${String(err)}`));
|
||||
if (isVerbose()) {
|
||||
const stdout = (err as { stdout?: string })?.stdout;
|
||||
const stderr = (err as { stderr?: string })?.stderr;
|
||||
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
||||
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
||||
}
|
||||
return runtime.exit(1);
|
||||
// Uniform messaging for EADDRINUSE with optional owner details.
|
||||
if (
|
||||
err instanceof PortInUseError ||
|
||||
(isErrno(err) && err.code === "EADDRINUSE")
|
||||
) {
|
||||
const details =
|
||||
err instanceof PortInUseError
|
||||
? err.details
|
||||
: await describePortOwner(port);
|
||||
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
||||
if (details) {
|
||||
runtime.error(info("Port listener details:"));
|
||||
runtime.error(details);
|
||||
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
||||
runtime.error(
|
||||
warn(
|
||||
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
runtime.error(danger(`${context} failed: ${String(err)}`));
|
||||
if (isVerbose()) {
|
||||
const stdout = (err as { stdout?: string })?.stdout;
|
||||
const stderr = (err as { stderr?: string })?.stderr;
|
||||
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
||||
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
||||
}
|
||||
return runtime.exit(1);
|
||||
}
|
||||
|
||||
export { PortInUseError };
|
||||
|
||||
+20
-20
@@ -3,26 +3,26 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { retryAsync } from "./retry.js";
|
||||
|
||||
describe("retryAsync", () => {
|
||||
it("returns on first success", async () => {
|
||||
const fn = vi.fn().mockResolvedValue("ok");
|
||||
const result = await retryAsync(fn, 3, 10);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("returns on first success", async () => {
|
||||
const fn = vi.fn().mockResolvedValue("ok");
|
||||
const result = await retryAsync(fn, 3, 10);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries then succeeds", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("fail1"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const result = await retryAsync(fn, 3, 1);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("retries then succeeds", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("fail1"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const result = await retryAsync(fn, 3, 1);
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("propagates after exhausting retries", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("propagates after exhausting retries", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
+15
-15
@@ -1,18 +1,18 @@
|
||||
export async function retryAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
attempts = 3,
|
||||
initialDelayMs = 300,
|
||||
fn: () => Promise<T>,
|
||||
attempts = 3,
|
||||
initialDelayMs = 300,
|
||||
): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i === attempts - 1) break;
|
||||
const delay = initialDelayMs * 2 ** i;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i === attempts - 1) break;
|
||||
const delay = initialDelayMs * 2 ** i;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
+51
-51
@@ -1,61 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
} from "./tailscale.js";
|
||||
|
||||
describe("tailscale helpers", () => {
|
||||
it("parses DNS name from tailscale status", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
||||
}),
|
||||
});
|
||||
const host = await getTailnetHostname(exec);
|
||||
expect(host).toBe("host.tailnet.ts.net");
|
||||
});
|
||||
it("parses DNS name from tailscale status", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
||||
}),
|
||||
});
|
||||
const host = await getTailnetHostname(exec);
|
||||
expect(host).toBe("host.tailnet.ts.net");
|
||||
});
|
||||
|
||||
it("falls back to IP when DNS missing", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
||||
});
|
||||
const host = await getTailnetHostname(exec);
|
||||
expect(host).toBe("100.2.2.2");
|
||||
});
|
||||
it("falls back to IP when DNS missing", async () => {
|
||||
const exec = vi.fn().mockResolvedValue({
|
||||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
||||
});
|
||||
const host = await getTailnetHostname(exec);
|
||||
expect(host).toBe("100.2.2.2");
|
||||
});
|
||||
|
||||
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("no go"))
|
||||
.mockResolvedValue({}); // brew install go
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await ensureGoInstalled(exec as never, prompt, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||||
});
|
||||
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("no go"))
|
||||
.mockResolvedValue({}); // brew install go
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await ensureGoInstalled(exec as never, prompt, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||||
});
|
||||
|
||||
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("missing"))
|
||||
.mockResolvedValue({});
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||
});
|
||||
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("missing"))
|
||||
.mockResolvedValue({});
|
||||
const prompt = vi.fn().mockResolvedValue(true);
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||
});
|
||||
});
|
||||
|
||||
+140
-140
@@ -6,158 +6,158 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { ensureBinary } from "./binaries.js";
|
||||
|
||||
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
||||
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
||||
const self =
|
||||
typeof parsed.Self === "object" && parsed.Self !== null
|
||||
? (parsed.Self as Record<string, unknown>)
|
||||
: undefined;
|
||||
const dns =
|
||||
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
||||
const ips = Array.isArray(self?.TailscaleIPs)
|
||||
? (self.TailscaleIPs as string[])
|
||||
: [];
|
||||
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
||||
if (ips.length > 0) return ips[0];
|
||||
throw new Error("Could not determine Tailscale DNS or IP");
|
||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
||||
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
||||
const self =
|
||||
typeof parsed.Self === "object" && parsed.Self !== null
|
||||
? (parsed.Self as Record<string, unknown>)
|
||||
: undefined;
|
||||
const dns =
|
||||
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
||||
const ips = Array.isArray(self?.TailscaleIPs)
|
||||
? (self.TailscaleIPs as string[])
|
||||
: [];
|
||||
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
||||
if (ips.length > 0) return ips[0];
|
||||
throw new Error("Could not determine Tailscale DNS or IP");
|
||||
}
|
||||
|
||||
export async function ensureGoInstalled(
|
||||
exec: typeof runExec = runExec,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
exec: typeof runExec = runExec,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
||||
const hasGo = await exec("go", ["version"]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (hasGo) return;
|
||||
const install = await prompt(
|
||||
"Go is not installed. Install via Homebrew (brew install go)?",
|
||||
true,
|
||||
);
|
||||
if (!install) {
|
||||
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose("Installing Go via Homebrew…");
|
||||
await exec("brew", ["install", "go"]);
|
||||
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
||||
const hasGo = await exec("go", ["version"]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (hasGo) return;
|
||||
const install = await prompt(
|
||||
"Go is not installed. Install via Homebrew (brew install go)?",
|
||||
true,
|
||||
);
|
||||
if (!install) {
|
||||
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose("Installing Go via Homebrew…");
|
||||
await exec("brew", ["install", "go"]);
|
||||
}
|
||||
|
||||
export async function ensureTailscaledInstalled(
|
||||
exec: typeof runExec = runExec,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
exec: typeof runExec = runExec,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
||||
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (hasTailscaled) return;
|
||||
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
||||
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (hasTailscaled) return;
|
||||
|
||||
const install = await prompt(
|
||||
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
||||
true,
|
||||
);
|
||||
if (!install) {
|
||||
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose("Installing tailscaled via Homebrew…");
|
||||
await exec("brew", ["install", "tailscale"]);
|
||||
const install = await prompt(
|
||||
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
||||
true,
|
||||
);
|
||||
if (!install) {
|
||||
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose("Installing tailscaled via Homebrew…");
|
||||
await exec("brew", ["install", "tailscale"]);
|
||||
}
|
||||
|
||||
export async function ensureFunnel(
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
prompt: typeof promptYesNo = promptYesNo,
|
||||
) {
|
||||
// Ensure Funnel is enabled and publish the webhook port.
|
||||
try {
|
||||
const statusOut = (
|
||||
await exec("tailscale", ["funnel", "status", "--json"])
|
||||
).stdout.trim();
|
||||
const parsed = statusOut
|
||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||
: {};
|
||||
if (!parsed || Object.keys(parsed).length === 0) {
|
||||
runtime.error(
|
||||
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
),
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
||||
),
|
||||
);
|
||||
const proceed = await prompt(
|
||||
"Attempt local setup with user-space tailscaled?",
|
||||
true,
|
||||
);
|
||||
if (!proceed) runtime.exit(1);
|
||||
await ensureBinary("brew", exec, runtime);
|
||||
await ensureGoInstalled(exec, prompt, runtime);
|
||||
await ensureTailscaledInstalled(exec, prompt, runtime);
|
||||
}
|
||||
// Ensure Funnel is enabled and publish the webhook port.
|
||||
try {
|
||||
const statusOut = (
|
||||
await exec("tailscale", ["funnel", "status", "--json"])
|
||||
).stdout.trim();
|
||||
const parsed = statusOut
|
||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||
: {};
|
||||
if (!parsed || Object.keys(parsed).length === 0) {
|
||||
runtime.error(
|
||||
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
),
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
||||
),
|
||||
);
|
||||
const proceed = await prompt(
|
||||
"Attempt local setup with user-space tailscaled?",
|
||||
true,
|
||||
);
|
||||
if (!proceed) runtime.exit(1);
|
||||
await ensureBinary("brew", exec, runtime);
|
||||
await ensureGoInstalled(exec, prompt, runtime);
|
||||
await ensureTailscaledInstalled(exec, prompt, runtime);
|
||||
}
|
||||
|
||||
logVerbose(`Enabling funnel on port ${port}…`);
|
||||
const { stdout } = await exec(
|
||||
"tailscale",
|
||||
["funnel", "--yes", "--bg", `${port}`],
|
||||
{
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
);
|
||||
if (stdout.trim()) console.log(stdout.trim());
|
||||
} catch (err) {
|
||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
||||
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
||||
if (stdout.includes("Funnel is not enabled")) {
|
||||
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
||||
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
||||
if (linkMatch) {
|
||||
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
||||
} else {
|
||||
console.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
stderr.includes("client version") ||
|
||||
stdout.includes("client version")
|
||||
) {
|
||||
console.error(
|
||||
warn(
|
||||
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.error(
|
||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||
),
|
||||
);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
||||
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.exit(1);
|
||||
}
|
||||
logVerbose(`Enabling funnel on port ${port}…`);
|
||||
const { stdout } = await exec(
|
||||
"tailscale",
|
||||
["funnel", "--yes", "--bg", `${port}`],
|
||||
{
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
);
|
||||
if (stdout.trim()) console.log(stdout.trim());
|
||||
} catch (err) {
|
||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
||||
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
||||
if (stdout.includes("Funnel is not enabled")) {
|
||||
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
||||
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
||||
if (linkMatch) {
|
||||
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
||||
} else {
|
||||
console.error(
|
||||
info(
|
||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
stderr.includes("client version") ||
|
||||
stdout.includes("client version")
|
||||
) {
|
||||
console.error(
|
||||
warn(
|
||||
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.error(
|
||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||
);
|
||||
runtime.error(
|
||||
info(
|
||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||
),
|
||||
);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
||||
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+71
-23
@@ -1,34 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { setVerbose } from "./globals.js";
|
||||
import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js";
|
||||
import { resetLogger, setLoggerOverride } from "./logging.js";
|
||||
import type { RuntimeEnv } from "./runtime.js";
|
||||
|
||||
describe("logger helpers", () => {
|
||||
it("formats messages through runtime log/error", () => {
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
setVerbose(false);
|
||||
});
|
||||
|
||||
logInfo("info", runtime);
|
||||
logWarn("warn", runtime);
|
||||
logSuccess("ok", runtime);
|
||||
logError("bad", runtime);
|
||||
it("formats messages through runtime log/error", () => {
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(3);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
logInfo("info", runtime);
|
||||
logWarn("warn", runtime);
|
||||
logSuccess("ok", runtime);
|
||||
logError("bad", runtime);
|
||||
|
||||
it("only logs debug when verbose is enabled", () => {
|
||||
const logVerbose = vi.spyOn(console, "log");
|
||||
setVerbose(false);
|
||||
logDebug("quiet");
|
||||
expect(logVerbose).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledTimes(3);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
setVerbose(true);
|
||||
logVerbose.mockClear();
|
||||
logDebug("loud");
|
||||
expect(logVerbose).toHaveBeenCalled();
|
||||
logVerbose.mockRestore();
|
||||
});
|
||||
it("only logs debug when verbose is enabled", () => {
|
||||
const logVerbose = vi.spyOn(console, "log");
|
||||
setVerbose(false);
|
||||
logDebug("quiet");
|
||||
expect(logVerbose).not.toHaveBeenCalled();
|
||||
|
||||
setVerbose(true);
|
||||
logVerbose.mockClear();
|
||||
logDebug("loud");
|
||||
expect(logVerbose).toHaveBeenCalled();
|
||||
logVerbose.mockRestore();
|
||||
});
|
||||
|
||||
it("writes to configured log file at configured level", () => {
|
||||
const logPath = pathForTest();
|
||||
cleanup(logPath);
|
||||
setLoggerOverride({ level: "debug", file: logPath });
|
||||
logInfo("hello");
|
||||
logDebug("debug-only");
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
expect(content).toContain("hello");
|
||||
expect(content).toContain("debug-only");
|
||||
cleanup(logPath);
|
||||
});
|
||||
|
||||
it("filters messages below configured level", () => {
|
||||
const logPath = pathForTest();
|
||||
cleanup(logPath);
|
||||
setLoggerOverride({ level: "warn", file: logPath });
|
||||
logInfo("info-only");
|
||||
logWarn("warn-only");
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
expect(content).not.toContain("info-only");
|
||||
expect(content).toContain("warn-only");
|
||||
cleanup(logPath);
|
||||
});
|
||||
});
|
||||
|
||||
function pathForTest() {
|
||||
return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`);
|
||||
}
|
||||
|
||||
function cleanup(file: string) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
+22
-16
@@ -1,36 +1,42 @@
|
||||
import {
|
||||
danger,
|
||||
info,
|
||||
isVerbose,
|
||||
logVerbose,
|
||||
success,
|
||||
warn,
|
||||
danger,
|
||||
info,
|
||||
isVerbose,
|
||||
logVerbose,
|
||||
success,
|
||||
warn,
|
||||
} from "./globals.js";
|
||||
import { getLogger } from "./logging.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||
|
||||
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||
runtime.log(info(message));
|
||||
runtime.log(info(message));
|
||||
getLogger().info(message);
|
||||
}
|
||||
|
||||
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||
runtime.log(warn(message));
|
||||
runtime.log(warn(message));
|
||||
getLogger().warn(message);
|
||||
}
|
||||
|
||||
export function logSuccess(
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
runtime.log(success(message));
|
||||
runtime.log(success(message));
|
||||
getLogger().info(message);
|
||||
}
|
||||
|
||||
export function logError(
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
message: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
runtime.error(danger(message));
|
||||
runtime.error(danger(message));
|
||||
getLogger().error(message);
|
||||
}
|
||||
|
||||
export function logDebug(message: string) {
|
||||
// Verbose helper that respects global verbosity flag.
|
||||
if (isVerbose()) logVerbose(message);
|
||||
// Always emit to file logger (level-filtered); console only when verbose.
|
||||
getLogger().debug(message);
|
||||
if (isVerbose()) logVerbose(message);
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import pino, { type Bindings, type LevelWithSilent, type Logger } from "pino";
|
||||
import { loadConfig, type WarelayConfig } from "./config/config.js";
|
||||
import { isVerbose } from "./globals.js";
|
||||
|
||||
const DEFAULT_LOG_DIR = path.join(os.tmpdir(), "warelay");
|
||||
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log");
|
||||
|
||||
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
||||
"silent",
|
||||
"fatal",
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug",
|
||||
"trace",
|
||||
];
|
||||
|
||||
export type LoggerSettings = {
|
||||
level?: LevelWithSilent;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
type ResolvedSettings = {
|
||||
level: LevelWithSilent;
|
||||
file: string;
|
||||
};
|
||||
export type LoggerResolvedSettings = ResolvedSettings;
|
||||
|
||||
let cachedLogger: Logger | null = null;
|
||||
let cachedSettings: ResolvedSettings | null = null;
|
||||
let overrideSettings: LoggerSettings | null = null;
|
||||
|
||||
function normalizeLevel(level?: string): LevelWithSilent {
|
||||
if (isVerbose()) return "debug";
|
||||
const candidate = level ?? "info";
|
||||
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
||||
? (candidate as LevelWithSilent)
|
||||
: "info";
|
||||
}
|
||||
|
||||
function resolveSettings(): ResolvedSettings {
|
||||
const cfg: WarelayConfig["logging"] | undefined =
|
||||
overrideSettings ?? loadConfig().logging;
|
||||
const level = normalizeLevel(cfg?.level);
|
||||
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
||||
return { level, file };
|
||||
}
|
||||
|
||||
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
||||
if (!a) return true;
|
||||
return a.level !== b.level || a.file !== b.file;
|
||||
}
|
||||
|
||||
function buildLogger(settings: ResolvedSettings): Logger {
|
||||
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
||||
const destination = pino.destination({
|
||||
dest: settings.file,
|
||||
mkdir: true,
|
||||
sync: true, // deterministic for tests; log volume is modest.
|
||||
});
|
||||
return pino(
|
||||
{
|
||||
level: settings.level,
|
||||
base: undefined,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
},
|
||||
destination,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLogger(): Logger {
|
||||
const settings = resolveSettings();
|
||||
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
||||
cachedLogger = buildLogger(settings);
|
||||
cachedSettings = settings;
|
||||
}
|
||||
return cachedLogger;
|
||||
}
|
||||
|
||||
export function getChildLogger(
|
||||
bindings?: Bindings,
|
||||
opts?: { level?: LevelWithSilent },
|
||||
): Logger {
|
||||
return getLogger().child(bindings ?? {}, opts);
|
||||
}
|
||||
|
||||
export function getResolvedLoggerSettings(): LoggerResolvedSettings {
|
||||
return resolveSettings();
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
export function setLoggerOverride(settings: LoggerSettings | null) {
|
||||
overrideSettings = settings;
|
||||
cachedLogger = null;
|
||||
cachedSettings = null;
|
||||
}
|
||||
|
||||
export function resetLogger() {
|
||||
cachedLogger = null;
|
||||
cachedSettings = null;
|
||||
overrideSettings = null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export const MAX_IMAGE_BYTES = 6 * 1024 * 1024; // 6MB
|
||||
export const MAX_AUDIO_BYTES = 16 * 1024 * 1024; // 16MB
|
||||
export const MAX_VIDEO_BYTES = 16 * 1024 * 1024; // 16MB
|
||||
export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||
|
||||
export function mediaKindFromMime(mime?: string | null): MediaKind {
|
||||
if (!mime) return "unknown";
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime === "application/pdf") return "document";
|
||||
if (mime.startsWith("application/")) return "document";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function maxBytesForKind(kind: MediaKind): number {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return MAX_IMAGE_BYTES;
|
||||
case "audio":
|
||||
return MAX_AUDIO_BYTES;
|
||||
case "video":
|
||||
return MAX_VIDEO_BYTES;
|
||||
case "document":
|
||||
return MAX_DOCUMENT_BYTES;
|
||||
default:
|
||||
return MAX_DOCUMENT_BYTES;
|
||||
}
|
||||
}
|
||||
+64
-64
@@ -12,11 +12,11 @@ const logInfo = vi.fn();
|
||||
vi.mock("./store.js", () => ({ saveMediaSource }));
|
||||
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
||||
vi.mock("../infra/ports.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../infra/ports.js")>(
|
||||
"../infra/ports.js",
|
||||
);
|
||||
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../infra/ports.js")>(
|
||||
"../infra/ports.js",
|
||||
);
|
||||
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
||||
});
|
||||
vi.mock("./server.js", () => ({ startMediaServer }));
|
||||
vi.mock("../logger.js", () => ({ logInfo }));
|
||||
@@ -25,69 +25,69 @@ const { ensureMediaHosted } = await import("./host.js");
|
||||
const { PortInUseError } = await import("../infra/ports.js");
|
||||
|
||||
describe("ensureMediaHosted", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("throws and cleans up when server not allowed to start", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id1",
|
||||
path: "/tmp/file1",
|
||||
size: 5,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tailnet-host");
|
||||
ensurePortAvailable.mockResolvedValue(undefined);
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
it("throws and cleans up when server not allowed to start", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id1",
|
||||
path: "/tmp/file1",
|
||||
size: 5,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tailnet-host");
|
||||
ensurePortAvailable.mockResolvedValue(undefined);
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
||||
).rejects.toThrow("requires the webhook/Funnel server");
|
||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
||||
rmSpy.mockRestore();
|
||||
});
|
||||
await expect(
|
||||
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
||||
).rejects.toThrow("requires the webhook/Funnel server");
|
||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
||||
rmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("starts media server when allowed", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id2",
|
||||
path: "/tmp/file2",
|
||||
size: 9,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tail.net");
|
||||
ensurePortAvailable.mockResolvedValue(undefined);
|
||||
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
||||
startMediaServer.mockResolvedValue(fakeServer);
|
||||
it("starts media server when allowed", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id2",
|
||||
path: "/tmp/file2",
|
||||
size: 9,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tail.net");
|
||||
ensurePortAvailable.mockResolvedValue(undefined);
|
||||
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
||||
startMediaServer.mockResolvedValue(fakeServer);
|
||||
|
||||
const result = await ensureMediaHosted("/tmp/file2", {
|
||||
startServer: true,
|
||||
port: 1234,
|
||||
});
|
||||
expect(startMediaServer).toHaveBeenCalledWith(
|
||||
1234,
|
||||
expect.any(Number),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(logInfo).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
url: "https://tail.net/media/id2",
|
||||
id: "id2",
|
||||
size: 9,
|
||||
});
|
||||
});
|
||||
const result = await ensureMediaHosted("/tmp/file2", {
|
||||
startServer: true,
|
||||
port: 1234,
|
||||
});
|
||||
expect(startMediaServer).toHaveBeenCalledWith(
|
||||
1234,
|
||||
expect.any(Number),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(logInfo).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
url: "https://tail.net/media/id2",
|
||||
id: "id2",
|
||||
size: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips server start when port already in use", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id3",
|
||||
path: "/tmp/file3",
|
||||
size: 7,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tail.net");
|
||||
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
||||
it("skips server start when port already in use", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
id: "id3",
|
||||
path: "/tmp/file3",
|
||||
size: 7,
|
||||
});
|
||||
getTailnetHostname.mockResolvedValue("tail.net");
|
||||
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
||||
|
||||
const result = await ensureMediaHosted("/tmp/file3", {
|
||||
startServer: false,
|
||||
port: 3000,
|
||||
});
|
||||
expect(startMediaServer).not.toHaveBeenCalled();
|
||||
expect(result.url).toBe("https://tail.net/media/id3");
|
||||
});
|
||||
const result = await ensureMediaHosted("/tmp/file3", {
|
||||
startServer: false,
|
||||
port: 3000,
|
||||
});
|
||||
expect(startMediaServer).not.toHaveBeenCalled();
|
||||
expect(result.url).toBe("https://tail.net/media/id3");
|
||||
});
|
||||
});
|
||||
|
||||
+40
-40
@@ -12,54 +12,54 @@ const TTL_MS = 2 * 60 * 1000;
|
||||
let mediaServer: import("http").Server | null = null;
|
||||
|
||||
export type HostedMedia = {
|
||||
url: string;
|
||||
id: string;
|
||||
size: number;
|
||||
url: string;
|
||||
id: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export async function ensureMediaHosted(
|
||||
source: string,
|
||||
opts: {
|
||||
port?: number;
|
||||
startServer?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
} = {},
|
||||
source: string,
|
||||
opts: {
|
||||
port?: number;
|
||||
startServer?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
} = {},
|
||||
): Promise<HostedMedia> {
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
|
||||
const saved = await saveMediaSource(source);
|
||||
const hostname = await getTailnetHostname();
|
||||
const saved = await saveMediaSource(source);
|
||||
const hostname = await getTailnetHostname();
|
||||
|
||||
// Decide whether we must start a media server.
|
||||
const needsServerStart = await isPortFree(port);
|
||||
if (needsServerStart && !opts.startServer) {
|
||||
await fs.rm(saved.path).catch(() => {});
|
||||
throw new Error(
|
||||
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
||||
);
|
||||
}
|
||||
if (needsServerStart && opts.startServer) {
|
||||
if (!mediaServer) {
|
||||
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
||||
logInfo(
|
||||
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
||||
runtime,
|
||||
);
|
||||
mediaServer.unref?.();
|
||||
}
|
||||
}
|
||||
// Decide whether we must start a media server.
|
||||
const needsServerStart = await isPortFree(port);
|
||||
if (needsServerStart && !opts.startServer) {
|
||||
await fs.rm(saved.path).catch(() => {});
|
||||
throw new Error(
|
||||
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
||||
);
|
||||
}
|
||||
if (needsServerStart && opts.startServer) {
|
||||
if (!mediaServer) {
|
||||
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
||||
logInfo(
|
||||
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
||||
runtime,
|
||||
);
|
||||
mediaServer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
const url = `https://${hostname}/media/${saved.id}`;
|
||||
return { url, id: saved.id, size: saved.size };
|
||||
const url = `https://${hostname}/media/${saved.id}`;
|
||||
return { url, id: saved.id, size: saved.size };
|
||||
}
|
||||
|
||||
async function isPortFree(port: number) {
|
||||
try {
|
||||
await ensurePortAvailable(port);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof PortInUseError) return false;
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await ensurePortAvailable(port);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof PortInUseError) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\s`]+)`?/i;
|
||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
|
||||
|
||||
export function normalizeMediaSource(src: string) {
|
||||
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
||||
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
||||
}
|
||||
|
||||
function cleanCandidate(raw: string) {
|
||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||
}
|
||||
|
||||
function isValidMedia(candidate: string) {
|
||||
if (!candidate) return false;
|
||||
if (candidate.length > 1024) return false;
|
||||
if (/\s/.test(candidate)) return false;
|
||||
return (
|
||||
/^https?:\/\//i.test(candidate) ||
|
||||
candidate.startsWith("/") ||
|
||||
candidate.startsWith("./")
|
||||
);
|
||||
if (!candidate) return false;
|
||||
if (candidate.length > 1024) return false;
|
||||
if (/\s/.test(candidate)) return false;
|
||||
return (
|
||||
/^https?:\/\//i.test(candidate) ||
|
||||
candidate.startsWith("/") ||
|
||||
candidate.startsWith("./")
|
||||
);
|
||||
}
|
||||
|
||||
export function splitMediaFromOutput(raw: string): {
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string; // legacy first item for backward compatibility
|
||||
} {
|
||||
const trimmedRaw = raw.trim();
|
||||
const match = MEDIA_TOKEN_RE.exec(trimmedRaw);
|
||||
if (!match?.[1]) return { text: trimmedRaw };
|
||||
const trimmedRaw = raw.trim();
|
||||
if (!trimmedRaw) return { text: "" };
|
||||
|
||||
const candidate = normalizeMediaSource(cleanCandidate(match[1]));
|
||||
const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
|
||||
const media: string[] = [];
|
||||
let foundMediaToken = false;
|
||||
|
||||
const cleanedText = mediaUrl
|
||||
? trimmedRaw
|
||||
.replace(match[0], "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim()
|
||||
: trimmedRaw
|
||||
.split("\n")
|
||||
.filter((line) => !MEDIA_TOKEN_RE.test(line))
|
||||
.join("\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
// Collect tokens line by line so we can strip them cleanly.
|
||||
const lines = trimmedRaw.split("\n");
|
||||
const keptLines: string[] = [];
|
||||
|
||||
return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText };
|
||||
for (const line of lines) {
|
||||
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
|
||||
if (matches.length === 0) {
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
foundMediaToken = true;
|
||||
const pieces: string[] = [];
|
||||
let cursor = 0;
|
||||
let hasValidMedia = false;
|
||||
|
||||
for (const match of matches) {
|
||||
const start = match.index ?? 0;
|
||||
pieces.push(line.slice(cursor, start));
|
||||
|
||||
const payload = match[1];
|
||||
const parts = payload.split(/\s+/).filter(Boolean);
|
||||
const invalidParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
const candidate = normalizeMediaSource(cleanCandidate(part));
|
||||
if (isValidMedia(candidate)) {
|
||||
media.push(candidate);
|
||||
hasValidMedia = true;
|
||||
} else {
|
||||
invalidParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidMedia && invalidParts.length > 0) {
|
||||
pieces.push(invalidParts.join(" "));
|
||||
}
|
||||
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
|
||||
pieces.push(line.slice(cursor));
|
||||
|
||||
const cleanedLine = pieces
|
||||
.join("")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
// If the line becomes empty, drop it.
|
||||
if (cleanedLine) {
|
||||
keptLines.push(cleanedLine);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanedText = keptLines
|
||||
.join("\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
|
||||
if (media.length === 0) {
|
||||
return { text: foundMediaToken ? cleanedText : trimmedRaw };
|
||||
}
|
||||
|
||||
return { text: cleanedText, mediaUrls: media, mediaUrl: media[0] };
|
||||
}
|
||||
|
||||
+33
-33
@@ -8,45 +8,45 @@ const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
|
||||
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("./store.js", () => ({
|
||||
getMediaDir: () => MEDIA_DIR,
|
||||
cleanOldMedia,
|
||||
getMediaDir: () => MEDIA_DIR,
|
||||
cleanOldMedia,
|
||||
}));
|
||||
|
||||
const { startMediaServer } = await import("./server.js");
|
||||
|
||||
describe("media server", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
});
|
||||
beforeAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("serves media and cleans up after send", async () => {
|
||||
const file = path.join(MEDIA_DIR, "file1");
|
||||
await fs.writeFile(file, "hello");
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/file1`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("hello");
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
it("serves media and cleans up after send", async () => {
|
||||
const file = path.join(MEDIA_DIR, "file1");
|
||||
await fs.writeFile(file, "hello");
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/file1`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("hello");
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("expires old media", async () => {
|
||||
const file = path.join(MEDIA_DIR, "old");
|
||||
await fs.writeFile(file, "stale");
|
||||
const past = Date.now() - 10_000;
|
||||
await fs.utimes(file, past / 1000, past / 1000);
|
||||
const server = await startMediaServer(0, 1_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/old`);
|
||||
expect(res.status).toBe(410);
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
it("expires old media", async () => {
|
||||
const file = path.join(MEDIA_DIR, "old");
|
||||
await fs.writeFile(file, "stale");
|
||||
const past = Date.now() - 10_000;
|
||||
await fs.utimes(file, past / 1000, past / 1000);
|
||||
const server = await startMediaServer(0, 1_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/old`);
|
||||
expect(res.status).toBe(410);
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
});
|
||||
|
||||
+42
-42
@@ -9,53 +9,53 @@ import { cleanOldMedia, getMediaDir } from "./store.js";
|
||||
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
export function attachMediaRoutes(
|
||||
app: Express,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
app: Express,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
_runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const mediaDir = getMediaDir();
|
||||
const mediaDir = getMediaDir();
|
||||
|
||||
app.get("/media/:id", async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const file = path.join(mediaDir, id);
|
||||
try {
|
||||
const stat = await fs.stat(file);
|
||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(file).catch(() => {});
|
||||
res.status(410).send("expired");
|
||||
return;
|
||||
}
|
||||
res.sendFile(file);
|
||||
// best-effort single-use cleanup after response ends
|
||||
res.on("finish", () => {
|
||||
setTimeout(() => {
|
||||
fs.rm(file).catch(() => {});
|
||||
}, 500);
|
||||
});
|
||||
} catch {
|
||||
res.status(404).send("not found");
|
||||
}
|
||||
});
|
||||
app.get("/media/:id", async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const file = path.join(mediaDir, id);
|
||||
try {
|
||||
const stat = await fs.stat(file);
|
||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(file).catch(() => {});
|
||||
res.status(410).send("expired");
|
||||
return;
|
||||
}
|
||||
res.sendFile(file);
|
||||
// best-effort single-use cleanup after response ends
|
||||
res.on("finish", () => {
|
||||
setTimeout(() => {
|
||||
fs.rm(file).catch(() => {});
|
||||
}, 500);
|
||||
});
|
||||
} catch {
|
||||
res.status(404).send("not found");
|
||||
}
|
||||
});
|
||||
|
||||
// periodic cleanup
|
||||
setInterval(() => {
|
||||
void cleanOldMedia(ttlMs);
|
||||
}, ttlMs).unref();
|
||||
// periodic cleanup
|
||||
setInterval(() => {
|
||||
void cleanOldMedia(ttlMs);
|
||||
}, ttlMs).unref();
|
||||
}
|
||||
|
||||
export async function startMediaServer(
|
||||
port: number,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
port: number,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<Server> {
|
||||
const app = express();
|
||||
attachMediaRoutes(app, ttlMs, runtime);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
server.once("listening", () => resolve(server));
|
||||
server.once("error", (err) => {
|
||||
runtime.error(danger(`Media server failed: ${String(err)}`));
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
const app = express();
|
||||
attachMediaRoutes(app, ttlMs, runtime);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
server.once("listening", () => resolve(server));
|
||||
server.once("error", (err) => {
|
||||
runtime.error(danger(`Media server failed: ${String(err)}`));
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+68
-40
@@ -1,60 +1,88 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
||||
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
default: { homedir: () => HOME },
|
||||
homedir: () => HOME,
|
||||
default: { homedir: () => HOME },
|
||||
homedir: () => HOME,
|
||||
}));
|
||||
|
||||
const store = await import("./store.js");
|
||||
|
||||
describe("media store", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
beforeAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates and returns media directory", async () => {
|
||||
const dir = await store.ensureMediaDir();
|
||||
expect(dir).toContain("warelay-home-test");
|
||||
const stat = await fs.stat(dir);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
it("creates and returns media directory", async () => {
|
||||
const dir = await store.ensureMediaDir();
|
||||
expect(dir).toContain("warelay-home-test");
|
||||
const stat = await fs.stat(dir);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("saves buffers and enforces size limit", async () => {
|
||||
const buf = Buffer.from("hello");
|
||||
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
||||
const savedStat = await fs.stat(saved.path);
|
||||
expect(savedStat.size).toBe(buf.length);
|
||||
expect(saved.contentType).toBe("text/plain");
|
||||
it("saves buffers and enforces size limit", async () => {
|
||||
const buf = Buffer.from("hello");
|
||||
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
||||
const savedStat = await fs.stat(saved.path);
|
||||
expect(savedStat.size).toBe(buf.length);
|
||||
expect(saved.contentType).toBe("text/plain");
|
||||
expect(saved.path.endsWith(".txt")).toBe(true);
|
||||
|
||||
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
||||
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||
"Media exceeds 5MB limit",
|
||||
);
|
||||
});
|
||||
const jpeg = await sharp({
|
||||
create: { width: 2, height: 2, channels: 3, background: "#123456" },
|
||||
})
|
||||
.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 srcFile = path.join(HOME, "tmp-src.txt");
|
||||
await fs.mkdir(HOME, { recursive: true });
|
||||
await fs.writeFile(srcFile, "local file");
|
||||
const saved = await store.saveMediaSource(srcFile);
|
||||
expect(saved.size).toBe(10);
|
||||
const savedStat = await fs.stat(saved.path);
|
||||
expect(savedStat.isFile()).toBe(true);
|
||||
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
||||
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||
"Media exceeds 5MB limit",
|
||||
);
|
||||
});
|
||||
|
||||
// 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("copies local files and cleans old media", async () => {
|
||||
const srcFile = path.join(HOME, "tmp-src.txt");
|
||||
await fs.mkdir(HOME, { recursive: true });
|
||||
await fs.writeFile(srcFile, "local file");
|
||||
const saved = await store.saveMediaSource(srcFile);
|
||||
expect(saved.size).toBe(10);
|
||||
const savedStat = await fs.stat(saved.path);
|
||||
expect(savedStat.isFile()).toBe(true);
|
||||
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 { pipeline } from "node:stream/promises";
|
||||
|
||||
import { detectMime, extensionForMime } from "./mime.js";
|
||||
|
||||
const MEDIA_DIR = path.join(os.homedir(), ".warelay", "media");
|
||||
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
export function getMediaDir() {
|
||||
return MEDIA_DIR;
|
||||
return MEDIA_DIR;
|
||||
}
|
||||
|
||||
export async function ensureMediaDir() {
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
return MEDIA_DIR;
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
return MEDIA_DIR;
|
||||
}
|
||||
|
||||
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
||||
await ensureMediaDir();
|
||||
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
entries.map(async (file) => {
|
||||
const full = path.join(MEDIA_DIR, file);
|
||||
const stat = await fs.stat(full).catch(() => null);
|
||||
if (!stat) return;
|
||||
if (now - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(full).catch(() => {});
|
||||
}
|
||||
}),
|
||||
);
|
||||
await ensureMediaDir();
|
||||
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
entries.map(async (file) => {
|
||||
const full = path.join(MEDIA_DIR, file);
|
||||
const stat = await fs.stat(full).catch(() => null);
|
||||
if (!stat) return;
|
||||
if (now - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(full).catch(() => {});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeUrl(src: string) {
|
||||
return /^https?:\/\//i.test(src);
|
||||
return /^https?:\/\//i.test(src);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download media to disk while capturing the first few KB for mime sniffing.
|
||||
*/
|
||||
async function downloadToFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = request(url, { headers }, (res) => {
|
||||
if (!res.statusCode || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
const out = createWriteStream(dest);
|
||||
res.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_BYTES) {
|
||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||
}
|
||||
});
|
||||
pipeline(res, out)
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
url: string,
|
||||
dest: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = request(url, { headers }, (res) => {
|
||||
if (!res.statusCode || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
const sniffChunks: Buffer[] = [];
|
||||
let sniffLen = 0;
|
||||
const out = createWriteStream(dest);
|
||||
res.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (sniffLen < 16384) {
|
||||
sniffChunks.push(chunk);
|
||||
sniffLen += chunk.length;
|
||||
}
|
||||
if (total > MAX_BYTES) {
|
||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||
}
|
||||
});
|
||||
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 = {
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export async function saveMediaSource(
|
||||
source: string,
|
||||
headers?: Record<string, string>,
|
||||
subdir = "",
|
||||
source: string,
|
||||
headers?: Record<string, string>,
|
||||
subdir = "",
|
||||
): Promise<SavedMedia> {
|
||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await cleanOldMedia();
|
||||
const id = crypto.randomUUID();
|
||||
const dest = path.join(dir, id);
|
||||
if (looksLikeUrl(source)) {
|
||||
await downloadToFile(source, dest, headers);
|
||||
const stat = await fs.stat(dest);
|
||||
return { id, path: dest, size: stat.size };
|
||||
}
|
||||
// local path
|
||||
const stat = await fs.stat(source);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Media path is not a file");
|
||||
}
|
||||
if (stat.size > MAX_BYTES) {
|
||||
throw new Error("Media exceeds 5MB limit");
|
||||
}
|
||||
await fs.copyFile(source, dest);
|
||||
return { id, path: dest, size: stat.size };
|
||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await cleanOldMedia();
|
||||
const id = crypto.randomUUID();
|
||||
if (looksLikeUrl(source)) {
|
||||
const tempDest = path.join(dir, `${id}.tmp`);
|
||||
const { headerMime, sniffBuffer, size } = await downloadToFile(
|
||||
source,
|
||||
tempDest,
|
||||
headers,
|
||||
);
|
||||
const mime = detectMime({
|
||||
buffer: sniffBuffer,
|
||||
headerMime,
|
||||
filePath: source,
|
||||
});
|
||||
const ext =
|
||||
extensionForMime(mime) ?? path.extname(new URL(source).pathname);
|
||||
const finalDest = path.join(dir, ext ? `${id}${ext}` : id);
|
||||
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(
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
subdir = "inbound",
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
subdir = "inbound",
|
||||
): Promise<SavedMedia> {
|
||||
if (buffer.byteLength > MAX_BYTES) {
|
||||
throw new Error("Media exceeds 5MB limit");
|
||||
}
|
||||
const dir = path.join(MEDIA_DIR, subdir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const id = crypto.randomUUID();
|
||||
const dest = path.join(dir, id);
|
||||
await fs.writeFile(dest, buffer);
|
||||
return { id, path: dest, size: buffer.byteLength, contentType };
|
||||
if (buffer.byteLength > MAX_BYTES) {
|
||||
throw new Error("Media exceeds 5MB limit");
|
||||
}
|
||||
const dir = path.join(MEDIA_DIR, subdir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const id = crypto.randomUUID();
|
||||
const mime = detectMime({ buffer, headerMime: contentType });
|
||||
const ext = extensionForMime(mime);
|
||||
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";
|
||||
|
||||
describe("command queue", () => {
|
||||
it("runs tasks one at a time in order", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const calls: number[] = [];
|
||||
it("runs tasks one at a time in order", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const calls: number[] = [];
|
||||
|
||||
const makeTask = (id: number) => async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
calls.push(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
active -= 1;
|
||||
return id;
|
||||
};
|
||||
const makeTask = (id: number) => async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
calls.push(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
active -= 1;
|
||||
return id;
|
||||
};
|
||||
|
||||
const results = await Promise.all([
|
||||
enqueueCommand(makeTask(1)),
|
||||
enqueueCommand(makeTask(2)),
|
||||
enqueueCommand(makeTask(3)),
|
||||
]);
|
||||
const results = await Promise.all([
|
||||
enqueueCommand(makeTask(1)),
|
||||
enqueueCommand(makeTask(2)),
|
||||
enqueueCommand(makeTask(3)),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
expect(maxActive).toBe(1);
|
||||
expect(getQueueSize()).toBe(0);
|
||||
});
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
expect(maxActive).toBe(1);
|
||||
expect(getQueueSize()).toBe(0);
|
||||
});
|
||||
|
||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||
let waited: number | null = null;
|
||||
let queuedAhead: number | null = null;
|
||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||
let waited: number | null = null;
|
||||
let queuedAhead: number | null = null;
|
||||
|
||||
// First task holds the queue long enough to trigger wait notice.
|
||||
const first = enqueueCommand(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
// First task holds the queue long enough to trigger wait notice.
|
||||
const first = enqueueCommand(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
});
|
||||
|
||||
const second = enqueueCommand(async () => {}, {
|
||||
warnAfterMs: 5,
|
||||
onWait: (ms, ahead) => {
|
||||
waited = ms;
|
||||
queuedAhead = ahead;
|
||||
},
|
||||
});
|
||||
const second = enqueueCommand(async () => {}, {
|
||||
warnAfterMs: 5,
|
||||
onWait: (ms, ahead) => {
|
||||
waited = ms;
|
||||
queuedAhead = ahead;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([first, second]);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(waited).not.toBeNull();
|
||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||
expect(queuedAhead).toBe(0);
|
||||
});
|
||||
expect(waited).not.toBeNull();
|
||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||
expect(queuedAhead).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,57 +2,57 @@
|
||||
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
||||
|
||||
type QueueEntry = {
|
||||
task: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
enqueuedAt: number;
|
||||
warnAfterMs: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
task: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
enqueuedAt: number;
|
||||
warnAfterMs: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
};
|
||||
|
||||
const queue: QueueEntry[] = [];
|
||||
let draining = false;
|
||||
|
||||
async function drainQueue() {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
while (queue.length) {
|
||||
const entry = queue.shift() as QueueEntry;
|
||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||
if (waitedMs >= entry.warnAfterMs) {
|
||||
entry.onWait?.(waitedMs, queue.length);
|
||||
}
|
||||
try {
|
||||
const result = await entry.task();
|
||||
entry.resolve(result);
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
draining = false;
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
while (queue.length) {
|
||||
const entry = queue.shift() as QueueEntry;
|
||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||
if (waitedMs >= entry.warnAfterMs) {
|
||||
entry.onWait?.(waitedMs, queue.length);
|
||||
}
|
||||
try {
|
||||
const result = await entry.task();
|
||||
entry.resolve(result);
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
draining = false;
|
||||
}
|
||||
|
||||
export function enqueueCommand<T>(
|
||||
task: () => Promise<T>,
|
||||
opts?: {
|
||||
warnAfterMs?: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
task: () => Promise<T>,
|
||||
opts?: {
|
||||
warnAfterMs?: number;
|
||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||
},
|
||||
): Promise<T> {
|
||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
queue.push({
|
||||
task: () => task(),
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
enqueuedAt: Date.now(),
|
||||
warnAfterMs,
|
||||
onWait: opts?.onWait,
|
||||
});
|
||||
void drainQueue();
|
||||
});
|
||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
queue.push({
|
||||
task: () => task(),
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
enqueuedAt: Date.now(),
|
||||
warnAfterMs,
|
||||
onWait: opts?.onWait,
|
||||
});
|
||||
void drainQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export function getQueueSize() {
|
||||
return queue.length + (draining ? 1 : 0);
|
||||
return queue.length + (draining ? 1 : 0);
|
||||
}
|
||||
|
||||
+73
-61
@@ -8,74 +8,86 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
// Simple promise-wrapped execFile with optional verbosity logging.
|
||||
export async function runExec(
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const options =
|
||||
typeof opts === "number"
|
||||
? { timeout: opts, encoding: "utf8" as const }
|
||||
: {
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: opts.maxBuffer,
|
||||
encoding: "utf8" as const,
|
||||
};
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) logDebug(stdout.trim());
|
||||
if (stderr.trim()) logError(stderr.trim());
|
||||
}
|
||||
return { stdout, stderr };
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const options =
|
||||
typeof opts === "number"
|
||||
? { timeout: opts, encoding: "utf8" as const }
|
||||
: {
|
||||
timeout: opts.timeoutMs,
|
||||
maxBuffer: opts.maxBuffer,
|
||||
encoding: "utf8" as const,
|
||||
};
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||
if (isVerbose()) {
|
||||
if (stdout.trim()) logDebug(stdout.trim());
|
||||
if (stderr.trim()) logError(stderr.trim());
|
||||
}
|
||||
return { stdout, stderr };
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type SpawnResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
};
|
||||
|
||||
export type CommandOptions = {
|
||||
timeoutMs: number;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export async function runCommandWithTimeout(
|
||||
argv: string[],
|
||||
timeoutMs: number,
|
||||
argv: string[],
|
||||
optionsOrTimeout: number | CommandOptions,
|
||||
): Promise<SpawnResult> {
|
||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
const options: CommandOptions =
|
||||
typeof optionsOrTimeout === "number"
|
||||
? { timeoutMs: optionsOrTimeout }
|
||||
: optionsOrTimeout;
|
||||
const { timeoutMs, cwd } = options;
|
||||
|
||||
child.stdout?.on("data", (d) => {
|
||||
stdout += d.toString();
|
||||
});
|
||||
child.stderr?.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
||||
});
|
||||
});
|
||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
cwd,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (d) => {
|
||||
stdout += d.toString();
|
||||
});
|
||||
child.stderr?.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,334 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import fsSync from "node:fs";
|
||||
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 {
|
||||
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();
|
||||
});
|
||||
|
||||
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 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("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
-633
@@ -1,633 +1,32 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { proto } from "@whiskeysockets/baileys";
|
||||
import {
|
||||
type AnyMessageContent,
|
||||
DisconnectReason,
|
||||
downloadMediaMessage,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
type WAMessage,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import pino from "pino";
|
||||
import qrcode from "qrcode-terminal";
|
||||
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { danger, info, isVerbose, logVerbose, success } from "./globals.js";
|
||||
import { logInfo } from "./logger.js";
|
||||
import { saveMediaBuffer } from "./media/store.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||
import type { Provider } from "./utils.js";
|
||||
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||
}
|
||||
|
||||
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
|
||||
|
||||
export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
||||
const logger = pino({ 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.2"],
|
||||
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 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;
|
||||
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,
|
||||
) {
|
||||
// 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,
|
||||
);
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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)" : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
// Barrel exports for the web provider pieces. Splitting the original 900+ line
|
||||
// module keeps responsibilities small and testable without changing the public API.
|
||||
export {
|
||||
DEFAULT_WEB_MEDIA_BYTES,
|
||||
HEARTBEAT_PROMPT,
|
||||
HEARTBEAT_TOKEN,
|
||||
monitorWebProvider,
|
||||
resolveHeartbeatRecipients,
|
||||
runWebHeartbeatOnce,
|
||||
type WebMonitorTuning,
|
||||
} from "./web/auto-reply.js";
|
||||
export {
|
||||
extractMediaPlaceholder,
|
||||
extractText,
|
||||
monitorWebInbox,
|
||||
type WebInboundMessage,
|
||||
type WebListenerCloseReason,
|
||||
} from "./web/inbound.js";
|
||||
export { loginWeb } from "./web/login.js";
|
||||
export { loadWebMedia, optimizeImageToJpeg } from "./web/media.js";
|
||||
export { sendMessageWeb } from "./web/outbound.js";
|
||||
export {
|
||||
createWaSocket,
|
||||
formatError,
|
||||
getStatusCode,
|
||||
logoutWeb,
|
||||
logWebSelfId,
|
||||
pickProvider,
|
||||
WA_WEB_AUTH_DIR,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
} from "./web/session.js";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export { createClient } from "../../twilio/client.js";
|
||||
export {
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
} from "../../twilio/messages.js";
|
||||
export { monitorTwilio } from "../../twilio/monitor.js";
|
||||
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
||||
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
||||
export { sendTypingIndicator } from "../../twilio/typing.js";
|
||||
export {
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
updateWebhook,
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
updateWebhook,
|
||||
} from "../../twilio/update-webhook.js";
|
||||
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";
|
||||
|
||||
@@ -4,16 +4,16 @@ import * as impl from "../../provider-web.js";
|
||||
import * as entry from "./index.js";
|
||||
|
||||
describe("providers/web entrypoint", () => {
|
||||
it("re-exports web provider helpers", () => {
|
||||
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
||||
expect(entry.loginWeb).toBe(impl.loginWeb);
|
||||
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
||||
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
||||
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||
});
|
||||
it("re-exports web provider helpers", () => {
|
||||
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
||||
expect(entry.loginWeb).toBe(impl.loginWeb);
|
||||
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
||||
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
||||
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-10
@@ -1,13 +1,13 @@
|
||||
/* istanbul ignore file */
|
||||
export {
|
||||
createWaSocket,
|
||||
loginWeb,
|
||||
logWebSelfId,
|
||||
monitorWebInbox,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
sendMessageWeb,
|
||||
WA_WEB_AUTH_DIR,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
createWaSocket,
|
||||
loginWeb,
|
||||
logWebSelfId,
|
||||
monitorWebInbox,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
sendMessageWeb,
|
||||
WA_WEB_AUTH_DIR,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
} from "../../provider-web.js";
|
||||
|
||||
+9
-9
@@ -1,14 +1,14 @@
|
||||
export type RuntimeEnv = {
|
||||
log: typeof console.log;
|
||||
error: typeof console.error;
|
||||
exit: (code: number) => never;
|
||||
log: typeof console.log;
|
||||
error: typeof console.error;
|
||||
exit: (code: number) => never;
|
||||
};
|
||||
|
||||
export const defaultRuntime: RuntimeEnv = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code) => {
|
||||
process.exit(code);
|
||||
throw new Error("unreachable"); // satisfies tests when mocked
|
||||
},
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code) => {
|
||||
process.exit(code);
|
||||
throw new Error("unreachable"); // satisfies tests when mocked
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,13 +2,13 @@ import Twilio from "twilio";
|
||||
import type { EnvConfig } from "../env.js";
|
||||
|
||||
export function createClient(env: EnvConfig) {
|
||||
// Twilio client using either auth token or API key/secret.
|
||||
if ("authToken" in env.auth) {
|
||||
return Twilio(env.accountSid, env.auth.authToken, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
}
|
||||
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
// Twilio client using either auth token or API key/secret.
|
||||
if ("authToken" in env.auth) {
|
||||
return Twilio(env.accountSid, env.auth.authToken, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
}
|
||||
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
export type ListedMessage = {
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
// Remove duplicates by SID while preserving order.
|
||||
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: ListedMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (seen.has(m.sid)) continue;
|
||||
seen.add(m.sid);
|
||||
deduped.push(m);
|
||||
}
|
||||
return deduped;
|
||||
const seen = new Set<string>();
|
||||
const deduped: ListedMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (seen.has(m.sid)) continue;
|
||||
seen.add(m.sid);
|
||||
deduped.push(m);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
// Sort messages newest -> oldest by dateCreated.
|
||||
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
||||
return [...messages].sort((a, b) => {
|
||||
const da = a.dateCreated?.getTime() ?? 0;
|
||||
const db = b.dateCreated?.getTime() ?? 0;
|
||||
return db - da;
|
||||
});
|
||||
return [...messages].sort((a, b) => {
|
||||
const da = a.dateCreated?.getTime() ?? 0;
|
||||
const db = b.dateCreated?.getTime() ?? 0;
|
||||
return db - da;
|
||||
});
|
||||
}
|
||||
|
||||
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
||||
export async function listRecentMessages(
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
): Promise<ListedMessage[]> {
|
||||
const env = readEnv();
|
||||
const client = clientOverride ?? createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||
const env = readEnv();
|
||||
const client = clientOverride ?? createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||
|
||||
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
||||
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
||||
const inbound = await client.messages.list({
|
||||
to: from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
const outbound = await client.messages.list({
|
||||
from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
||||
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
||||
const inbound = await client.messages.list({
|
||||
to: from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
const outbound = await client.messages.list({
|
||||
from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
|
||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||
const combined = uniqueBySid(
|
||||
[...inboundArr, ...outboundArr].map((m) => ({
|
||||
sid: m.sid,
|
||||
status: m.status ?? null,
|
||||
direction: m.direction ?? null,
|
||||
dateCreated: m.dateCreated,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
body: m.body,
|
||||
errorCode: m.errorCode ?? null,
|
||||
errorMessage: m.errorMessage ?? null,
|
||||
})),
|
||||
);
|
||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||
const combined = uniqueBySid(
|
||||
[...inboundArr, ...outboundArr].map((m) => ({
|
||||
sid: m.sid,
|
||||
status: m.status ?? null,
|
||||
direction: m.direction ?? null,
|
||||
dateCreated: m.dateCreated,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
body: m.body,
|
||||
errorCode: m.errorCode ?? null,
|
||||
errorMessage: m.errorMessage ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
return sortByDateDesc(combined).slice(0, limit);
|
||||
return sortByDateDesc(combined).slice(0, limit);
|
||||
}
|
||||
|
||||
// Human-friendly single-line formatter for recent messages.
|
||||
export function formatMessageLine(m: ListedMessage): string {
|
||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||
const dir =
|
||||
m.direction === "inbound"
|
||||
? "⬅️ "
|
||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||
? "➡️ "
|
||||
: "↔️ ";
|
||||
const status = m.status ?? "unknown";
|
||||
const err =
|
||||
m.errorCode != null
|
||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||
: "";
|
||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||
const bodyPreview =
|
||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||
const dir =
|
||||
m.direction === "inbound"
|
||||
? "⬅️ "
|
||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||
? "➡️ "
|
||||
: "↔️ ";
|
||||
const status = m.status ?? "unknown";
|
||||
const err =
|
||||
m.errorCode != null
|
||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||
: "";
|
||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||
const bodyPreview =
|
||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||
}
|
||||
|
||||
+37
-37
@@ -3,43 +3,43 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { monitorTwilio } from "./monitor.js";
|
||||
|
||||
describe("monitorTwilio", () => {
|
||||
it("processes inbound messages once with injected deps", async () => {
|
||||
const listRecentMessages = vi.fn().mockResolvedValue([
|
||||
{
|
||||
sid: "m1",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date(),
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
status: null,
|
||||
},
|
||||
]);
|
||||
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
||||
const readEnv = vi.fn(() => ({
|
||||
accountSid: "AC",
|
||||
whatsappFrom: "whatsapp:+1",
|
||||
auth: { accountSid: "AC", authToken: "t" },
|
||||
}));
|
||||
const createClient = vi.fn(
|
||||
() => ({ messages: { create: vi.fn() } }) as never,
|
||||
);
|
||||
const sleep = vi.fn().mockResolvedValue(undefined);
|
||||
it("processes inbound messages once with injected deps", async () => {
|
||||
const listRecentMessages = vi.fn().mockResolvedValue([
|
||||
{
|
||||
sid: "m1",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date(),
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
status: null,
|
||||
},
|
||||
]);
|
||||
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
||||
const readEnv = vi.fn(() => ({
|
||||
accountSid: "AC",
|
||||
whatsappFrom: "whatsapp:+1",
|
||||
auth: { accountSid: "AC", authToken: "t" },
|
||||
}));
|
||||
const createClient = vi.fn(
|
||||
() => ({ messages: { create: vi.fn() } }) as never,
|
||||
);
|
||||
const sleep = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await monitorTwilio(0, 0, {
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
maxIterations: 1,
|
||||
});
|
||||
await monitorTwilio(0, 0, {
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
maxIterations: 1,
|
||||
});
|
||||
|
||||
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
||||
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
||||
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+96
-96
@@ -8,122 +8,122 @@ import { sleep, withWhatsAppPrefix } from "../utils.js";
|
||||
import { createClient } from "./client.js";
|
||||
|
||||
type MonitorDeps = {
|
||||
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||
listRecentMessages: (
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
) => Promise<ListedMessage[]>;
|
||||
readEnv: typeof readEnv;
|
||||
createClient: typeof createClient;
|
||||
sleep: typeof sleep;
|
||||
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||
listRecentMessages: (
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
) => Promise<ListedMessage[]>;
|
||||
readEnv: typeof readEnv;
|
||||
createClient: typeof createClient;
|
||||
sleep: typeof sleep;
|
||||
};
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||
|
||||
export type ListedMessage = {
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated: Date | undefined;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode: number | null;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
type MonitorOptions = {
|
||||
client?: ReturnType<typeof createClient>;
|
||||
maxIterations?: number;
|
||||
deps?: MonitorDeps;
|
||||
runtime?: RuntimeEnv;
|
||||
client?: ReturnType<typeof createClient>;
|
||||
maxIterations?: number;
|
||||
deps?: MonitorDeps;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
const defaultDeps: MonitorDeps = {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages: () => Promise.resolve([]),
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages: () => Promise.resolve([]),
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
};
|
||||
|
||||
// Poll Twilio for inbound messages and auto-reply when configured.
|
||||
export async function monitorTwilio(
|
||||
pollSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
opts?: MonitorOptions,
|
||||
pollSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
opts?: MonitorOptions,
|
||||
) {
|
||||
const deps = opts?.deps ?? defaultDeps;
|
||||
const runtime = opts?.runtime ?? defaultRuntime;
|
||||
const maxIterations = opts?.maxIterations ?? Infinity;
|
||||
let backoffMs = 1_000;
|
||||
const deps = opts?.deps ?? defaultDeps;
|
||||
const runtime = opts?.runtime ?? defaultRuntime;
|
||||
const maxIterations = opts?.maxIterations ?? Infinity;
|
||||
let backoffMs = 1_000;
|
||||
|
||||
const env = deps.readEnv(runtime);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const client = opts?.client ?? deps.createClient(env);
|
||||
logInfo(
|
||||
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||
runtime,
|
||||
);
|
||||
const env = deps.readEnv(runtime);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const client = opts?.client ?? deps.createClient(env);
|
||||
logInfo(
|
||||
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||
runtime,
|
||||
);
|
||||
|
||||
let lastSeenSid: string | undefined;
|
||||
let iterations = 0;
|
||||
while (iterations < maxIterations) {
|
||||
let messages: ListedMessage[] = [];
|
||||
try {
|
||||
messages =
|
||||
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||
backoffMs = 1_000; // reset after success
|
||||
} catch (err) {
|
||||
logWarn(
|
||||
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
await deps.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * 2, 10_000);
|
||||
continue;
|
||||
}
|
||||
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
||||
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
||||
const newestFirst = [...inboundOnly].sort(
|
||||
(a, b) =>
|
||||
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
||||
);
|
||||
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
||||
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||
iterations += 1;
|
||||
if (iterations >= maxIterations) break;
|
||||
await deps.sleep(
|
||||
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
||||
);
|
||||
}
|
||||
let lastSeenSid: string | undefined;
|
||||
let iterations = 0;
|
||||
while (iterations < maxIterations) {
|
||||
let messages: ListedMessage[] = [];
|
||||
try {
|
||||
messages =
|
||||
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||
backoffMs = 1_000; // reset after success
|
||||
} catch (err) {
|
||||
logWarn(
|
||||
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
await deps.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * 2, 10_000);
|
||||
continue;
|
||||
}
|
||||
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
||||
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
||||
const newestFirst = [...inboundOnly].sort(
|
||||
(a, b) =>
|
||||
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
||||
);
|
||||
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
||||
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||
iterations += 1;
|
||||
if (iterations >= maxIterations) break;
|
||||
await deps.sleep(
|
||||
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessages(
|
||||
messages: ListedMessage[],
|
||||
client: ReturnType<typeof createClient>,
|
||||
lastSeenSid: string | undefined,
|
||||
deps: MonitorDeps,
|
||||
runtime: RuntimeEnv,
|
||||
messages: ListedMessage[],
|
||||
client: ReturnType<typeof createClient>,
|
||||
lastSeenSid: string | undefined,
|
||||
deps: MonitorDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
for (const m of messages) {
|
||||
if (!m.sid) continue;
|
||||
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
||||
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
||||
if (m.direction !== "inbound") continue;
|
||||
if (!m.from || !m.to) continue;
|
||||
try {
|
||||
await deps.autoReplyIfConfigured(
|
||||
client as unknown as import("./types.js").TwilioRequester & {
|
||||
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||
},
|
||||
m as unknown as MessageInstance,
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
for (const m of messages) {
|
||||
if (!m.sid) continue;
|
||||
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
||||
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
||||
if (m.direction !== "inbound") continue;
|
||||
if (!m.from || !m.to) continue;
|
||||
try {
|
||||
await deps.autoReplyIfConfigured(
|
||||
client as unknown as import("./types.js").TwilioRequester & {
|
||||
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||
},
|
||||
m as unknown as MessageInstance,
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-25
@@ -3,30 +3,30 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { waitForFinalStatus } from "./send.js";
|
||||
|
||||
describe("twilio send helpers", () => {
|
||||
it("waitForFinalStatus resolves on delivered", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "queued" })
|
||||
.mockResolvedValueOnce({ status: "delivered" });
|
||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("waitForFinalStatus resolves on delivered", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "queued" })
|
||||
.mockResolvedValueOnce({ status: "delivered" });
|
||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waitForFinalStatus exits on failure", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||
const runtime = {
|
||||
log: console.log,
|
||||
error: () => {},
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
} as never;
|
||||
await expect(
|
||||
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
it("waitForFinalStatus exits on failure", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||
const runtime = {
|
||||
log: console.log,
|
||||
error: () => {},
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
} as never;
|
||||
await expect(
|
||||
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
+48
-48
@@ -10,60 +10,60 @@ const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
|
||||
|
||||
// Send outbound WhatsApp message; exit non-zero on API failure.
|
||||
export async function sendMessage(
|
||||
to: string,
|
||||
body: string,
|
||||
opts?: { mediaUrl?: string },
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
to: string,
|
||||
body: string,
|
||||
opts?: { mediaUrl?: string },
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const env = readEnv(runtime);
|
||||
const client = createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const toNumber = withWhatsAppPrefix(to);
|
||||
const env = readEnv(runtime);
|
||||
const client = createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const toNumber = withWhatsAppPrefix(to);
|
||||
|
||||
try {
|
||||
const message = await client.messages.create({
|
||||
from,
|
||||
to: toNumber,
|
||||
body,
|
||||
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
||||
});
|
||||
try {
|
||||
const message = await client.messages.create({
|
||||
from,
|
||||
to: toNumber,
|
||||
body,
|
||||
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
||||
});
|
||||
|
||||
logInfo(
|
||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||
runtime,
|
||||
);
|
||||
return { client, sid: message.sid };
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, toNumber, runtime);
|
||||
}
|
||||
logInfo(
|
||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||
runtime,
|
||||
);
|
||||
return { client, sid: message.sid };
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, toNumber, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll message status until delivered/failed or timeout.
|
||||
export async function waitForFinalStatus(
|
||||
client: ReturnType<typeof createClient>,
|
||||
sid: string,
|
||||
timeoutSeconds: number,
|
||||
pollSeconds: number,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
client: ReturnType<typeof createClient>,
|
||||
sid: string,
|
||||
timeoutSeconds: number,
|
||||
pollSeconds: number,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const m = await client.messages(sid).fetch();
|
||||
const status = m.status ?? "unknown";
|
||||
if (successTerminalStatuses.has(status)) {
|
||||
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||
return;
|
||||
}
|
||||
if (failureTerminalStatuses.has(status)) {
|
||||
runtime.error(
|
||||
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
await sleep(pollSeconds * 1000);
|
||||
}
|
||||
logInfo(
|
||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||
runtime,
|
||||
);
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const m = await client.messages(sid).fetch();
|
||||
const status = m.status ?? "unknown";
|
||||
if (successTerminalStatuses.has(status)) {
|
||||
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||
return;
|
||||
}
|
||||
if (failureTerminalStatuses.has(status)) {
|
||||
runtime.error(
|
||||
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
await sleep(pollSeconds * 1000);
|
||||
}
|
||||
logInfo(
|
||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
+45
-45
@@ -4,50 +4,50 @@ import { withWhatsAppPrefix } from "../utils.js";
|
||||
import type { TwilioSenderListClient } from "./types.js";
|
||||
|
||||
export async function findWhatsappSenderSid(
|
||||
client: TwilioSenderListClient,
|
||||
from: string,
|
||||
explicitSenderSid?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
client: TwilioSenderListClient,
|
||||
from: string,
|
||||
explicitSenderSid?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
||||
if (explicitSenderSid) {
|
||||
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
||||
return explicitSenderSid;
|
||||
}
|
||||
try {
|
||||
// Prefer official SDK list helper to avoid request-shape mismatches.
|
||||
// Twilio helper types are broad; we narrow to expected shape.
|
||||
const senderClient = client as unknown as TwilioSenderListClient;
|
||||
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
||||
channel: "whatsapp",
|
||||
pageSize: 50,
|
||||
});
|
||||
if (!senders) {
|
||||
throw new Error('List senders response missing "senders" array');
|
||||
}
|
||||
const match = senders.find(
|
||||
(s) =>
|
||||
(typeof s.senderId === "string" &&
|
||||
s.senderId === withWhatsAppPrefix(from)) ||
|
||||
(typeof s.sender_id === "string" &&
|
||||
s.sender_id === withWhatsAppPrefix(from)),
|
||||
);
|
||||
if (!match || typeof match.sid !== "string") {
|
||||
throw new Error(
|
||||
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
||||
);
|
||||
}
|
||||
return match.sid;
|
||||
} catch (err) {
|
||||
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
||||
if (isVerbose()) {
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
||||
if (explicitSenderSid) {
|
||||
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
||||
return explicitSenderSid;
|
||||
}
|
||||
try {
|
||||
// Prefer official SDK list helper to avoid request-shape mismatches.
|
||||
// Twilio helper types are broad; we narrow to expected shape.
|
||||
const senderClient = client as unknown as TwilioSenderListClient;
|
||||
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
||||
channel: "whatsapp",
|
||||
pageSize: 50,
|
||||
});
|
||||
if (!senders) {
|
||||
throw new Error('List senders response missing "senders" array');
|
||||
}
|
||||
const match = senders.find(
|
||||
(s) =>
|
||||
(typeof s.senderId === "string" &&
|
||||
s.senderId === withWhatsAppPrefix(from)) ||
|
||||
(typeof s.sender_id === "string" &&
|
||||
s.sender_id === withWhatsAppPrefix(from)),
|
||||
);
|
||||
if (!match || typeof match.sid !== "string") {
|
||||
throw new Error(
|
||||
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
||||
);
|
||||
}
|
||||
return match.sid;
|
||||
} catch (err) {
|
||||
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
||||
if (isVerbose()) {
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-51
@@ -1,79 +1,79 @@
|
||||
export type TwilioRequestOptions = {
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type TwilioSender = { sid: string; sender_id: string };
|
||||
|
||||
export type TwilioRequestResponse = {
|
||||
data?: {
|
||||
senders?: TwilioSender[];
|
||||
};
|
||||
data?: {
|
||||
senders?: TwilioSender[];
|
||||
};
|
||||
};
|
||||
|
||||
export type IncomingNumber = {
|
||||
sid: string;
|
||||
phoneNumber: string;
|
||||
smsUrl?: string;
|
||||
sid: string;
|
||||
phoneNumber: string;
|
||||
smsUrl?: string;
|
||||
};
|
||||
|
||||
export type TwilioChannelsSender = {
|
||||
sid?: string;
|
||||
senderId?: string;
|
||||
sender_id?: string;
|
||||
webhook?: {
|
||||
callback_url?: string;
|
||||
callback_method?: string;
|
||||
fallback_url?: string;
|
||||
fallback_method?: string;
|
||||
};
|
||||
sid?: string;
|
||||
senderId?: string;
|
||||
sender_id?: string;
|
||||
webhook?: {
|
||||
callback_url?: string;
|
||||
callback_method?: string;
|
||||
fallback_url?: string;
|
||||
fallback_method?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelSenderUpdater = {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type IncomingPhoneNumberUpdater = {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type IncomingPhoneNumbersClient = {
|
||||
list: (params: {
|
||||
phoneNumber: string;
|
||||
limit?: number;
|
||||
}) => Promise<IncomingNumber[]>;
|
||||
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||
list: (params: {
|
||||
phoneNumber: string;
|
||||
limit?: number;
|
||||
}) => Promise<IncomingNumber[]>;
|
||||
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
||||
|
||||
export type TwilioSenderListClient = {
|
||||
messaging: {
|
||||
v2: {
|
||||
channelsSenders: {
|
||||
list: (params: {
|
||||
channel: string;
|
||||
pageSize: number;
|
||||
}) => Promise<TwilioChannelsSender[]>;
|
||||
(
|
||||
sid: string,
|
||||
): ChannelSenderUpdater & {
|
||||
fetch: () => Promise<TwilioChannelsSender>;
|
||||
};
|
||||
};
|
||||
};
|
||||
v1: {
|
||||
services: (sid: string) => {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||
messaging: {
|
||||
v2: {
|
||||
channelsSenders: {
|
||||
list: (params: {
|
||||
channel: string;
|
||||
pageSize: number;
|
||||
}) => Promise<TwilioChannelsSender[]>;
|
||||
(
|
||||
sid: string,
|
||||
): ChannelSenderUpdater & {
|
||||
fetch: () => Promise<TwilioChannelsSender>;
|
||||
};
|
||||
};
|
||||
};
|
||||
v1: {
|
||||
services: (sid: string) => {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||
};
|
||||
|
||||
export type TwilioRequester = {
|
||||
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
||||
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
||||
};
|
||||
|
||||
+31
-31
@@ -2,42 +2,42 @@ import { isVerbose, logVerbose, warn } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
type TwilioRequestOptions = {
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type TwilioRequester = {
|
||||
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
||||
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function sendTypingIndicator(
|
||||
client: TwilioRequester,
|
||||
runtime: RuntimeEnv,
|
||||
messageSid?: string,
|
||||
client: TwilioRequester,
|
||||
runtime: RuntimeEnv,
|
||||
messageSid?: string,
|
||||
) {
|
||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||
if (!messageSid) {
|
||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.request({
|
||||
method: "post",
|
||||
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
||||
form: {
|
||||
messageId: messageSid,
|
||||
channel: "whatsapp",
|
||||
},
|
||||
});
|
||||
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
}
|
||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||
if (!messageSid) {
|
||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.request({
|
||||
method: "post",
|
||||
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
||||
form: {
|
||||
messageId: messageSid,
|
||||
channel: "whatsapp",
|
||||
},
|
||||
});
|
||||
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
} from "./update-webhook.js";
|
||||
|
||||
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
||||
|
||||
describe("update-webhook helpers", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
||||
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
||||
});
|
||||
beforeEach(() => {
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
||||
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.entries(envBackup).forEach(([k, v]) => {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.entries(envBackup).forEach(([k, v]) => {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
});
|
||||
});
|
||||
|
||||
it("findIncomingNumberSid returns first match", async () => {
|
||||
const client = {
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
||||
},
|
||||
} as never;
|
||||
const sid = await findIncomingNumberSid(client);
|
||||
expect(sid).toBe("PN1");
|
||||
});
|
||||
it("findIncomingNumberSid returns first match", async () => {
|
||||
const client = {
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
||||
},
|
||||
} as never;
|
||||
const sid = await findIncomingNumberSid(client);
|
||||
expect(sid).toBe("PN1");
|
||||
});
|
||||
|
||||
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
||||
const client = {
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||
},
|
||||
} as never;
|
||||
const sid = await findMessagingServiceSid(client);
|
||||
expect(sid).toBe("MG1");
|
||||
});
|
||||
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
||||
const client = {
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||
},
|
||||
} as never;
|
||||
const sid = await findMessagingServiceSid(client);
|
||||
expect(sid).toBe("MG1");
|
||||
});
|
||||
|
||||
it("setMessagingServiceWebhook updates via service helper", async () => {
|
||||
const update = async (_: unknown) => {};
|
||||
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
||||
const client = {
|
||||
messaging: {
|
||||
v1: {
|
||||
services: () => ({ update, fetch }),
|
||||
},
|
||||
},
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||
},
|
||||
} as never;
|
||||
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
it("setMessagingServiceWebhook updates via service helper", async () => {
|
||||
const update = async (_: unknown) => {};
|
||||
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
||||
const client = {
|
||||
messaging: {
|
||||
v1: {
|
||||
services: () => ({ update, fetch }),
|
||||
},
|
||||
},
|
||||
incomingPhoneNumbers: {
|
||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||
},
|
||||
} as never;
|
||||
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+170
-170
@@ -6,193 +6,193 @@ import type { createClient } from "./client.js";
|
||||
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
|
||||
|
||||
export async function findIncomingNumberSid(
|
||||
client: TwilioSenderListClient,
|
||||
client: TwilioSenderListClient,
|
||||
): Promise<string | null> {
|
||||
// Look up incoming phone number SID matching the configured WhatsApp number.
|
||||
try {
|
||||
const env = readEnv();
|
||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||
const list = await client.incomingPhoneNumbers.list({
|
||||
phoneNumber: phone,
|
||||
limit: 1,
|
||||
});
|
||||
return list?.[0]?.sid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Look up incoming phone number SID matching the configured WhatsApp number.
|
||||
try {
|
||||
const env = readEnv();
|
||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||
const list = await client.incomingPhoneNumbers.list({
|
||||
phoneNumber: phone,
|
||||
limit: 1,
|
||||
});
|
||||
return list?.[0]?.sid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findMessagingServiceSid(
|
||||
client: TwilioSenderListClient,
|
||||
client: TwilioSenderListClient,
|
||||
): Promise<string | null> {
|
||||
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||
try {
|
||||
const env = readEnv();
|
||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||
const list = await client.incomingPhoneNumbers.list({
|
||||
phoneNumber: phone,
|
||||
limit: 1,
|
||||
});
|
||||
const msid =
|
||||
(list?.[0] as IncomingNumberWithService | undefined)
|
||||
?.messagingServiceSid ?? null;
|
||||
return msid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||
try {
|
||||
const env = readEnv();
|
||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||
const list = await client.incomingPhoneNumbers.list({
|
||||
phoneNumber: phone,
|
||||
limit: 1,
|
||||
});
|
||||
const msid =
|
||||
(list?.[0] as IncomingNumberWithService | undefined)
|
||||
?.messagingServiceSid ?? null;
|
||||
return msid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMessagingServiceWebhook(
|
||||
client: TwilioSenderListClient,
|
||||
url: string,
|
||||
method: "POST" | "GET",
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
client: TwilioSenderListClient,
|
||||
url: string,
|
||||
method: "POST" | "GET",
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<boolean> {
|
||||
const msid = await findMessagingServiceSid(client);
|
||||
if (!msid) return false;
|
||||
try {
|
||||
await client.messaging.v1.services(msid).update({
|
||||
InboundRequestUrl: url,
|
||||
InboundRequestMethod: method,
|
||||
});
|
||||
const fetched = await client.messaging.v1.services(msid).fetch();
|
||||
const stored = fetched?.inboundRequestUrl;
|
||||
logInfo(
|
||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||
runtime,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const msid = await findMessagingServiceSid(client);
|
||||
if (!msid) return false;
|
||||
try {
|
||||
await client.messaging.v1.services(msid).update({
|
||||
InboundRequestUrl: url,
|
||||
InboundRequestMethod: method,
|
||||
});
|
||||
const fetched = await client.messaging.v1.services(msid).fetch();
|
||||
const stored = fetched?.inboundRequestUrl;
|
||||
logInfo(
|
||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||
runtime,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
||||
export async function updateWebhook(
|
||||
client: ReturnType<typeof createClient>,
|
||||
senderSid: string,
|
||||
url: string,
|
||||
method: "POST" | "GET" = "POST",
|
||||
runtime: RuntimeEnv,
|
||||
client: ReturnType<typeof createClient>,
|
||||
senderSid: string,
|
||||
url: string,
|
||||
method: "POST" | "GET" = "POST",
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
// Point Twilio sender webhook at the provided URL.
|
||||
const requester = client as unknown as TwilioRequester;
|
||||
const clientTyped = client as unknown as TwilioSenderListClient;
|
||||
// Point Twilio sender webhook at the provided URL.
|
||||
const requester = client as unknown as TwilioRequester;
|
||||
const clientTyped = client as unknown as TwilioSenderListClient;
|
||||
|
||||
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
||||
try {
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||
body: {
|
||||
webhook: {
|
||||
callback_url: url,
|
||||
callback_method: method,
|
||||
},
|
||||
},
|
||||
contentType: "application/json",
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
if (storedUrl) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
||||
try {
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||
body: {
|
||||
webhook: {
|
||||
callback_url: url,
|
||||
callback_method: method,
|
||||
},
|
||||
},
|
||||
contentType: "application/json",
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
if (storedUrl) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 1b) Form-encoded fallback for older Twilio stacks
|
||||
try {
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||
form: {
|
||||
"Webhook.CallbackUrl": url,
|
||||
"Webhook.CallbackMethod": method,
|
||||
},
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
if (storedUrl) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
// 1b) Form-encoded fallback for older Twilio stacks
|
||||
try {
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||
form: {
|
||||
"Webhook.CallbackUrl": url,
|
||||
"Webhook.CallbackMethod": method,
|
||||
},
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
if (storedUrl) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 2) SDK helper fallback (if supported by this client)
|
||||
try {
|
||||
if (clientTyped.messaging?.v2?.channelsSenders) {
|
||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||
callbackUrl: url,
|
||||
callbackMethod: method,
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
logInfo(
|
||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||
runtime,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
// 2) SDK helper fallback (if supported by this client)
|
||||
try {
|
||||
if (clientTyped.messaging?.v2?.channelsSenders) {
|
||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||
callbackUrl: url,
|
||||
callbackMethod: method,
|
||||
});
|
||||
const fetched = await clientTyped.messaging.v2
|
||||
.channelsSenders(senderSid)
|
||||
.fetch();
|
||||
const storedUrl =
|
||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||
logInfo(
|
||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||
runtime,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 3) Incoming phone number fallback (works for many WA senders)
|
||||
try {
|
||||
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||
if (phoneSid) {
|
||||
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
||||
smsUrl: url,
|
||||
smsMethod: method,
|
||||
});
|
||||
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
// 3) Incoming phone number fallback (works for many WA senders)
|
||||
try {
|
||||
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||
if (phoneSid) {
|
||||
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
||||
smsUrl: url,
|
||||
smsMethod: method,
|
||||
});
|
||||
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
runtime.error(
|
||||
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
||||
);
|
||||
runtime.error(
|
||||
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
||||
);
|
||||
}
|
||||
|
||||
+25
-25
@@ -2,36 +2,36 @@ import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
type TwilioApiError = {
|
||||
code?: number | string;
|
||||
status?: number | string;
|
||||
message?: string;
|
||||
moreInfo?: string;
|
||||
response?: { body?: unknown };
|
||||
code?: number | string;
|
||||
status?: number | string;
|
||||
message?: string;
|
||||
moreInfo?: string;
|
||||
response?: { body?: unknown };
|
||||
};
|
||||
|
||||
export function formatTwilioError(err: unknown): string {
|
||||
// Normalize Twilio error objects into a single readable string.
|
||||
const e = err as TwilioApiError;
|
||||
const pieces = [];
|
||||
if (e.code != null) pieces.push(`code ${e.code}`);
|
||||
if (e.status != null) pieces.push(`status ${e.status}`);
|
||||
if (e.message) pieces.push(e.message);
|
||||
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
||||
return pieces.length ? pieces.join(" | ") : String(err);
|
||||
// Normalize Twilio error objects into a single readable string.
|
||||
const e = err as TwilioApiError;
|
||||
const pieces = [];
|
||||
if (e.code != null) pieces.push(`code ${e.code}`);
|
||||
if (e.status != null) pieces.push(`status ${e.status}`);
|
||||
if (e.message) pieces.push(e.message);
|
||||
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
||||
return pieces.length ? pieces.join(" | ") : String(err);
|
||||
}
|
||||
|
||||
export function logTwilioSendError(
|
||||
err: unknown,
|
||||
destination?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
err: unknown,
|
||||
destination?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Friendly error logger for send failures, including response body when present.
|
||||
const prefix = destination ? `to ${destination}: ` : "";
|
||||
runtime.error(
|
||||
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
||||
);
|
||||
const body = (err as TwilioApiError)?.response?.body;
|
||||
if (body) {
|
||||
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
||||
}
|
||||
// Friendly error logger for send failures, including response body when present.
|
||||
const prefix = destination ? `to ${destination}: ` : "";
|
||||
runtime.error(
|
||||
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
||||
);
|
||||
const body = (err as TwilioApiError)?.response?.body;
|
||||
if (body) {
|
||||
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
+123
-123
@@ -5,7 +5,7 @@ import express, { type Request, type Response } from "express";
|
||||
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
|
||||
import { type EnvConfig, readEnv } from "../env.js";
|
||||
import { danger, success } from "../globals.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import * as mediaHost from "../media/host.js";
|
||||
import { attachMediaRoutes } from "../media/server.js";
|
||||
import { saveMediaSource } from "../media/store.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
@@ -16,143 +16,143 @@ import { logTwilioSendError } from "./utils.js";
|
||||
|
||||
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
||||
export async function startWebhook(
|
||||
port: number,
|
||||
path = "/webhook/whatsapp",
|
||||
autoReply: string | undefined,
|
||||
verbose: boolean,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
port: number,
|
||||
path = "/webhook/whatsapp",
|
||||
autoReply: string | undefined,
|
||||
verbose: boolean,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<Server> {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const env = readEnv(runtime);
|
||||
const app = express();
|
||||
const normalizedPath = normalizePath(path);
|
||||
const env = readEnv(runtime);
|
||||
const app = express();
|
||||
|
||||
attachMediaRoutes(app, undefined, runtime);
|
||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||
next();
|
||||
});
|
||||
attachMediaRoutes(app, undefined, runtime);
|
||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||
next();
|
||||
});
|
||||
|
||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||
runtime.log(`
|
||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||
runtime.log(`
|
||||
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
||||
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||
|
||||
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||
let mediaPath: string | undefined;
|
||||
let mediaUrlInbound: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||
mediaType =
|
||||
typeof req.body?.MediaContentType0 === "string"
|
||||
? (req.body.MediaContentType0 as string)
|
||||
: undefined;
|
||||
try {
|
||||
const creds = buildTwilioBasicAuth(env);
|
||||
const saved = await saveMediaSource(
|
||||
mediaUrlInbound,
|
||||
{
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||
} catch (err) {
|
||||
runtime.error(
|
||||
danger(`Failed to download inbound media: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||
let mediaPath: string | undefined;
|
||||
let mediaUrlInbound: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||
mediaType =
|
||||
typeof req.body?.MediaContentType0 === "string"
|
||||
? (req.body.MediaContentType0 as string)
|
||||
: undefined;
|
||||
try {
|
||||
const creds = buildTwilioBasicAuth(env);
|
||||
const saved = await saveMediaSource(
|
||||
mediaUrlInbound,
|
||||
{
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||
} catch (err) {
|
||||
runtime.error(
|
||||
danger(`Failed to download inbound media: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = createClient(env);
|
||||
let replyResult: ReplyPayload | undefined =
|
||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||
if (!replyResult) {
|
||||
replyResult = await getReplyFromConfig(
|
||||
{
|
||||
Body,
|
||||
From,
|
||||
To,
|
||||
MessageSid,
|
||||
MediaPath: mediaPath,
|
||||
MediaUrl: mediaUrlInbound,
|
||||
MediaType: mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||
},
|
||||
);
|
||||
}
|
||||
const client = createClient(env);
|
||||
let replyResult: ReplyPayload | undefined =
|
||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||
if (!replyResult) {
|
||||
replyResult = await getReplyFromConfig(
|
||||
{
|
||||
Body,
|
||||
From,
|
||||
To,
|
||||
MessageSid,
|
||||
MediaPath: mediaPath,
|
||||
MediaUrl: mediaUrlInbound,
|
||||
MediaType: mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: To,
|
||||
to: From,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (verbose)
|
||||
runtime.log(
|
||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||
);
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, From ?? undefined, runtime);
|
||||
}
|
||||
}
|
||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await mediaHost.ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: To,
|
||||
to: From,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (verbose)
|
||||
runtime.log(
|
||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||
);
|
||||
} catch (err) {
|
||||
logTwilioSendError(err, From ?? undefined, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
// Respond 200 OK to Twilio.
|
||||
res.type("text/xml").send("<Response></Response>");
|
||||
});
|
||||
// Respond 200 OK to Twilio.
|
||||
res.type("text/xml").send("<Response></Response>");
|
||||
});
|
||||
|
||||
app.use((_req, res) => {
|
||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||
res.status(404).send("warelay webhook: not found");
|
||||
});
|
||||
app.use((_req, res) => {
|
||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||
res.status(404).send("warelay webhook: not found");
|
||||
});
|
||||
|
||||
// Start server and resolve once listening; reject on bind error.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
// Start server and resolve once listening; reject on bind error.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(port);
|
||||
|
||||
const onListening = () => {
|
||||
cleanup();
|
||||
runtime.log(
|
||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||
);
|
||||
resolve(server);
|
||||
};
|
||||
const onListening = () => {
|
||||
cleanup();
|
||||
runtime.log(
|
||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||
);
|
||||
resolve(server);
|
||||
};
|
||||
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
server.off("listening", onListening);
|
||||
server.off("error", onError);
|
||||
};
|
||||
const cleanup = () => {
|
||||
server.off("listening", onListening);
|
||||
server.off("error", onError);
|
||||
};
|
||||
|
||||
server.once("listening", onListening);
|
||||
server.once("error", onError);
|
||||
});
|
||||
server.once("listening", onListening);
|
||||
server.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTwilioBasicAuth(env: EnvConfig) {
|
||||
if ("authToken" in env.auth) {
|
||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
||||
"base64",
|
||||
);
|
||||
if ("authToken" in env.auth) {
|
||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user