Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c194247dab | ||
|
|
a48420d85f | ||
|
|
5c66e8273b | ||
|
|
5992e629c3 | ||
|
|
765d67cd18 | ||
|
|
baf20af17f | ||
|
|
e482e7768b | ||
|
|
8682352edb | ||
|
|
ef1222ff31 | ||
|
|
0145f3a585 | ||
|
|
4a8bb56a1e | ||
|
|
ce5b02a9ad | ||
|
|
5c8ce41e12 | ||
|
|
a2586b8b06 | ||
|
|
1fd4485716 | ||
|
|
b029ab933e | ||
|
|
e0b28b6718 | ||
|
|
4dd2f3b7f7 | ||
|
|
e5f677803f | ||
|
|
a67f4db5e2 | ||
|
|
8a01dc7f4c | ||
|
|
e107f115e2 | ||
|
|
af8af4881b | ||
|
|
d871dad85f | ||
|
|
5b83d30887 | ||
|
|
2e3b8a03aa | ||
|
|
d924b7d283 | ||
|
|
e0425ad3e1 | ||
|
|
5dced02a20 | ||
|
|
e642f128ae | ||
|
|
7d0ae151e8 | ||
|
|
f945e284e1 | ||
|
|
7166efef08 | ||
|
|
a81689e902 | ||
|
|
0a0418b973 | ||
|
|
f81f432af5 | ||
|
|
dda017df23 | ||
|
|
46be5eac7d | ||
|
|
e4076d14c0 | ||
|
|
20fc412765 | ||
|
|
c251681a40 |
No files matched your search
@@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.1.0 — 2025-11-26
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5 MB) to avoid provider/API limits.
|
||||||
|
- Web provider now detects media kind (image/audio/video/document), logs the source path, and enforces provider caps: images ≤6 MB, audio/video ≤16 MB, documents ≤100 MB; images still target the configurable cap above with resize + JPEG recompress.
|
||||||
|
- Sessions can now send the system prompt only once: set `inbound.reply.session.sendSystemOnce` (optional `sessionIntro` for the first turn) to avoid re-sending large prompts every message.
|
||||||
|
- While commands run, typing indicators refresh every 30s by default (tune with `inbound.reply.typingIntervalSeconds`); helps keep WhatsApp “composing” visible during longer Claude runs.
|
||||||
|
- Optional voice-note transcription: set `inbound.transcribeAudio.command` (e.g., OpenAI Whisper CLI) to turn inbound audio into text before templating/Claude; verbose logs surface when transcription runs. Prompts now include the original media path plus a `Transcript:` block so models see both.
|
||||||
|
- Auto-reply command replies now return structured `{ payload, meta }`, respect `mediaMaxMb` for local media, log Claude metadata, and include the command `cwd` in timeout messages for easier debugging.
|
||||||
|
- Added unit coverage for command helper edge cases (Claude flags, session args, media tokens, timeouts) and transcription download/command invocation.
|
||||||
|
- Split the monolithic web provider into focused modules under `src/web/` plus a barrel; added logout command, no-fallback relay behavior, and web-only relay start helper.
|
||||||
|
- Introduced structured reconnect/heartbeat logging (`web-reconnect`, `web-heartbeat`), bounded exponential backoff with CLI and config knobs, and a troubleshooting guide at `docs/refactor/web-relay-troubleshooting.md`.
|
||||||
|
- Relay help now prints effective heartbeat/backoff settings when running in web mode for quick triage.
|
||||||
|
|
||||||
|
## 1.0.4 — 2025-11-25
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Auto-replies now send a WhatsApp fallback message when a command/Claude run hits the timeout, including up to 800 chars of partial stdout so the user still sees progress.
|
||||||
|
- Added tests covering the new timeout fallback behavior and partial-output truncation.
|
||||||
|
- Web relay auto-reconnects after Baileys/WebSocket drops (with log-out detection) and exposes close events for monitoring; added tests for close propagation and reconnect loop.
|
||||||
|
|
||||||
## 0.1.3 — 2025-11-25
|
## 0.1.3 — 2025-11-25
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
|
|||||||
**A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)**
|
**A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)**
|
||||||
1. Link your account: `warelay login` (scan the QR).
|
1. Link your account: `warelay login` (scan the QR).
|
||||||
2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"` (add `--provider web` if you want to force the web session).
|
2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"` (add `--provider web` if you want to force the web session).
|
||||||
3. Stay online & auto-reply: `warelay relay --verbose` (defaults to Web when logged in, falls back to Twilio otherwise).
|
3. Stay online & auto-reply: `warelay relay --verbose` (uses Web when you're logged in; if you're not linked, start it with `--provider twilio`). When a Web session drops, the relay exits instead of silently falling back so you notice and re-login.
|
||||||
|
|
||||||
**B) Twilio WhatsApp number (for delivery status + webhooks)**
|
**B) Twilio WhatsApp number (for delivery status + webhooks)**
|
||||||
1. Copy `.env.example` → `.env`; set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` **or** `TWILIO_API_KEY`/`TWILIO_API_SECRET`, and `TWILIO_WHATSAPP_FROM=whatsapp:+19995550123` (optional `TWILIO_SENDER_SID`).
|
1. Copy `.env.example` → `.env`; set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` **or** `TWILIO_API_KEY`/`TWILIO_API_SECRET`, and `TWILIO_WHATSAPP_FROM=whatsapp:+19995550123` (optional `TWILIO_SENDER_SID`).
|
||||||
@@ -46,15 +46,41 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
|
|||||||
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
||||||
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
||||||
|
|
||||||
### Sending images
|
### Sending media
|
||||||
- Twilio: `warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media` (needs `warelay webhook --ingress tailscale` or `--serve-media` to auto-host via Funnel; max 5 MB).
|
- Twilio: `warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media` (needs `warelay webhook --ingress tailscale` or `--serve-media` to auto-host via Funnel; max 5 MB per file because of the built-in host).
|
||||||
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed).
|
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed). Web auto-detects media kind: images (≤6 MB), audio/voice or video (≤16 MB), other docs (≤100 MB). Images are resized to max 2048px and JPEG recompressed when the cap would be exceeded.
|
||||||
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present).
|
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present). Web auto-replies honor `inbound.reply.mediaMaxMb` (default 5 MB) as a post-compression target but will never exceed the provider hard limits above.
|
||||||
|
|
||||||
|
### Voice notes (optional transcription)
|
||||||
|
- If you set `inbound.transcribeAudio.command`, warelay will run that CLI when inbound audio arrives (e.g., WhatsApp voice notes) and replace the Body with the transcript before templating/Claude.
|
||||||
|
- Example using OpenAI Whisper CLI (requires `OPENAI_API_KEY`):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
inbound: {
|
||||||
|
transcribeAudio: {
|
||||||
|
command: [
|
||||||
|
"openai",
|
||||||
|
"api",
|
||||||
|
"audio.transcriptions.create",
|
||||||
|
"-m",
|
||||||
|
"whisper-1",
|
||||||
|
"-f",
|
||||||
|
"{{MediaPath}}",
|
||||||
|
"--response-format",
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
timeoutSeconds: 45
|
||||||
|
},
|
||||||
|
reply: { mode: "command", command: ["claude", "{{Body}}"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Works for Web and Twilio providers; verbose mode logs when transcription runs. The command prompt includes the original media path plus a `Transcript:` block so models see both. If transcription fails, the original Body is used.
|
||||||
|
|
||||||
## Providers
|
## Providers
|
||||||
- **Twilio (default):** needs `.env` creds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators.
|
- **Twilio (default):** needs `.env` creds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators.
|
||||||
- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out).
|
- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out). If the Web socket closes, the relay exits instead of pivoting to Twilio.
|
||||||
- **Auto-select (`relay` only):** `--provider auto` uses Web when logged in, otherwise Twilio polling.
|
- **Auto-select (`relay` only):** `--provider auto` picks Web when a cache exists at start, otherwise Twilio polling. It will not swap from Web to Twilio mid-run if the Web session drops.
|
||||||
|
|
||||||
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
|
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
|
||||||
|
|
||||||
@@ -107,7 +133,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
|||||||
### Claude CLI setup (how we run it)
|
### Claude CLI setup (how we run it)
|
||||||
1) Install the official Claude CLI (e.g., `brew install anthropic-ai/cli/claude` or follow the Anthropic docs) and run `claude login` so it can read your API key.
|
1) Install the official Claude CLI (e.g., `brew install anthropic-ai/cli/claude` or follow the Anthropic docs) and run `claude login` so it can read your API key.
|
||||||
2) In `warelay.json`, set `reply.mode` to `"command"` and point `command[0]` to `"claude"`; set `claudeOutputFormat` to `"text"` (or `"json"`/`"stream-json"` if you want warelay to parse and trim the JSON output).
|
2) In `warelay.json`, set `reply.mode` to `"command"` and point `command[0]` to `"claude"`; set `claudeOutputFormat` to `"text"` (or `"json"`/`"stream-json"` if you want warelay to parse and trim the JSON output).
|
||||||
3) (Optional) Add `bodyPrefix` to inject a system prompt and `session` settings to keep multi-turn context (`/new` resets by default).
|
3) (Optional) Add `bodyPrefix` to inject a system prompt and `session` settings to keep multi-turn context (`/new` resets by default). Set `sendSystemOnce: true` (plus an optional `sessionIntro`) to only send that prompt on the first turn of each session.
|
||||||
4) Run `pnpm warelay relay --provider auto` (or `--provider web|twilio`) and send a WhatsApp message; warelay will queue the Claude call, stream typing indicators (Twilio provider), parse the result, and send back the text.
|
4) Run `pnpm warelay relay --provider auto` (or `--provider web|twilio`) and send a WhatsApp message; warelay will queue the Claude call, stream typing indicators (Twilio provider), parse the result, and send back the text.
|
||||||
|
|
||||||
### Auto-reply parameter table (compact)
|
### Auto-reply parameter table (compact)
|
||||||
@@ -125,6 +151,9 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
|||||||
| `inbound.reply.session.resetTriggers` | `string[]` (default: `["/new"]`) | Exact match or prefix (`/new hi`) resets session. |
|
| `inbound.reply.session.resetTriggers` | `string[]` (default: `["/new"]`) | Exact match or prefix (`/new hi`) resets session. |
|
||||||
| `inbound.reply.session.idleMinutes` | `number` (default: `60`) | Session expires after idle period. |
|
| `inbound.reply.session.idleMinutes` | `number` (default: `60`) | Session expires after idle period. |
|
||||||
| `inbound.reply.session.store` | `string` (default: `~/.warelay/sessions.json`) | Custom session store path. |
|
| `inbound.reply.session.store` | `string` (default: `~/.warelay/sessions.json`) | Custom session store path. |
|
||||||
|
| `inbound.reply.session.sendSystemOnce` | `boolean` (default: `false`) | If `true`, only include the system prompt/template on the first turn of a session. |
|
||||||
|
| `inbound.reply.session.sessionIntro` | `string` | Optional intro text sent once per new session (prepended before the body when `sendSystemOnce` is used). |
|
||||||
|
| `inbound.reply.typingIntervalSeconds` | `number` (default: `8` for command replies) | How often to refresh typing indicators while the command/Claude run is in flight. |
|
||||||
| `inbound.reply.session.sessionArgNew` | `string[]` (default: `["--session-id","{{SessionId}}"]`) | Args injected for a new session run. |
|
| `inbound.reply.session.sessionArgNew` | `string[]` (default: `["--session-id","{{SessionId}}"]`) | Args injected for a new session run. |
|
||||||
| `inbound.reply.session.sessionArgResume` | `string[]` (default: `["--resume","{{SessionId}}"]`) | Args for resumed sessions. |
|
| `inbound.reply.session.sessionArgResume` | `string[]` (default: `["--resume","{{SessionId}}"]`) | Args for resumed sessions. |
|
||||||
| `inbound.reply.session.sessionArgBeforeBody` | `boolean` (default: `true`) | Place session args before final body arg. |
|
| `inbound.reply.session.sessionArgBeforeBody` | `boolean` (default: `true`) | Place session args before final body arg. |
|
||||||
@@ -142,6 +171,11 @@ Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{Mess
|
|||||||
- Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`.
|
- Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`.
|
||||||
- Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
|
- Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
|
||||||
|
|
||||||
|
### Maintainer notes (web provider internals)
|
||||||
|
- Web logic lives under `src/web/`: `session.ts` (auth/cache + provider pick), `login.ts` (QR login/logout), `outbound.ts`/`inbound.ts` (send/receive plumbing), `auto-reply.ts` (relay loop + reconnect/backoff), `media.ts` (download/resize helpers), and `reconnect.ts` (shared retry math). `test-helpers.ts` provides fixtures.
|
||||||
|
- The public surface remains the `src/provider-web.ts` barrel so existing imports keep working.
|
||||||
|
- Reconnects are capped and logged; no Twilio fallback occurs after a Web disconnect—restart the relay after re-linking.
|
||||||
|
|
||||||
## FAQ & Safety
|
## FAQ & Safety
|
||||||
- Twilio errors: **63016 “permission to send an SMS has not been enabled”** → ensure your number is WhatsApp-enabled; **63007 template not approved** → send a free-form session message within 24h or use an approved template; **63112 policy violation** → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run `pnpm warelay status` to see the exact Twilio response body.
|
- Twilio errors: **63016 “permission to send an SMS has not been enabled”** → ensure your number is WhatsApp-enabled; **63007 template not approved** → send a free-form session message within 24h or use an approved template; **63112 policy violation** → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run `pnpm warelay status` to see the exact Twilio response body.
|
||||||
- Does this store my messages? warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs stream to stdout/stderr and also `/tmp/warelay/warelay.log` (configurable via `logging.file`).
|
- Does this store my messages? warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs stream to stdout/stderr and also `/tmp/warelay/warelay.log` (configurable via `logging.file`).
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "https://biomejs.dev/schemas/biome.json",
|
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentWidth": 2
|
"indentWidth": 2,
|
||||||
|
"indentStyle": "space"
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
+1
-1
@@ -30,6 +30,6 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
|||||||
|
|
||||||
6) **Post-publish**
|
6) **Post-publish**
|
||||||
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
||||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z`; body should be the product-facing bullets from the changelog; attach the `npm pack` tarball + checksums if you generated them.
|
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `warelay X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**; attach the `npm pack` tarball + checksums if you generated them.
|
||||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y warelay@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
- [ ] From a clean temp directory (no `package.json`), run `npx -y warelay@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||||
- [ ] Announce/share release notes.
|
- [ ] Announce/share release notes.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Audio / Voice Notes — 2025-11-25
|
||||||
|
|
||||||
|
## What works
|
||||||
|
- **Optional transcription**: If `inbound.transcribeAudio.command` is set in `~/.warelay/warelay.json`, warelay will:
|
||||||
|
1) Download inbound audio (Web or Twilio) to a temp path if only a URL is present.
|
||||||
|
2) Run the configured CLI (templated with `{{MediaPath}}`), expecting transcript on stdout.
|
||||||
|
3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both.
|
||||||
|
4) Continue through the normal auto-reply pipeline (templating, sessions, Claude/command).
|
||||||
|
- **Verbose logging**: In `--verbose`, we log when transcription runs and when the transcript replaces the body.
|
||||||
|
|
||||||
|
## Config example (OpenAI Whisper CLI)
|
||||||
|
Requires `OPENAI_API_KEY` in env and `openai` CLI installed:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
inbound: {
|
||||||
|
transcribeAudio: {
|
||||||
|
command: [
|
||||||
|
"openai",
|
||||||
|
"api",
|
||||||
|
"audio.transcriptions.create",
|
||||||
|
"-m",
|
||||||
|
"whisper-1",
|
||||||
|
"-f",
|
||||||
|
"{{MediaPath}}",
|
||||||
|
"--response-format",
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
timeoutSeconds: 45
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
mode: "command",
|
||||||
|
command: ["claude", "{{Body}}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes & limits
|
||||||
|
- We don’t ship a transcriber; you opt in with any CLI that prints text to stdout (Whisper cloud, whisper.cpp, vosk, Deepgram, etc.).
|
||||||
|
- Size guard: inbound audio must be ≤5 MB (matches the temp media store and transcript pipeline).
|
||||||
|
- Outbound caps: Web can send audio/voice up to 16 MB (sends as a voice note with `ptt: true`); Twilio still uses the 5 MB media host guard.
|
||||||
|
- If transcription fails, we fall back to the original body/media note; replies still go through.
|
||||||
|
- Transcript is available to templates as `{{Transcript}}`; models get both the media path and a `Transcript:` block in the prompt when using command mode.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
|
||||||
|
- Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
|
||||||
|
- Twilio paths are hosted URLs; Web paths are local. The temp download uses HTTPS for Twilio and a temp file for Web-only media.
|
||||||
@@ -59,6 +59,10 @@ Notes on this configuration:
|
|||||||
- Host local paths for Twilio using the media server/Tailscale Funnel.
|
- Host local paths for Twilio using the media server/Tailscale Funnel.
|
||||||
- Send buffers directly for the Web provider.
|
- 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.
|
- 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.
|
||||||
|
- Outbound media from Claude (via `MEDIA:`) follows provider caps: Web resizes images to the configured target (`inbound.reply.mediaMaxMb`, default 5 MB) within hard limits of 6 MB (image), 16 MB (audio/video voice notes), and 100 MB (documents); Twilio still uses the Funnel host with a 5 MB guard.
|
||||||
|
- Voice notes: set `inbound.transcribeAudio.command` to run a CLI that emits the transcript to stdout (e.g., OpenAI Whisper: `openai api audio.transcriptions.create -m whisper-1 -f {{MediaPath}} --response-format text`). If it succeeds, warelay replaces `Body` with the transcript and adds the original media path plus a `Transcript:` block into the prompt before invoking Claude.
|
||||||
|
- To avoid re-sending long system prompts every turn, set `inbound.reply.session.sendSystemOnce: true` and keep your prompt in `bodyPrefix` or `sessionIntro`; they are sent only on the first message of each session (resets on `/new` or idle expiry).
|
||||||
|
- Typing indicators: for long-running Claude/command replies, `inbound.reply.typingIntervalSeconds` (or the session-level equivalent) refreshes the “composing” indicator periodically (default 8 s for command replies).
|
||||||
|
|
||||||
## Testing the setup
|
## Testing the setup
|
||||||
1. Start a relay (auto-selects Web when logged in, otherwise Twilio polling):
|
1. Start a relay (auto-selects Web when logged in, otherwise Twilio polling):
|
||||||
|
|||||||
+10
-5
@@ -5,6 +5,7 @@ This document defines how `warelay` should handle sending and replying with imag
|
|||||||
## Goals
|
## Goals
|
||||||
- Allow sending an image with an optional caption via `warelay send` for both providers.
|
- Allow sending an image with an optional caption via `warelay send` for both providers.
|
||||||
- Allow auto-replies (Twilio webhook, Twilio poller, Web inbox) to return an image (optionally with text) when configured.
|
- Allow auto-replies (Twilio webhook, Twilio poller, Web inbox) to return an image (optionally with text) when configured.
|
||||||
|
- For the Web provider, also support audio/voice, video, and generic documents with sensible per-type limits.
|
||||||
- Keep the “one command at a time” queue intact; media fetch/serve must not block other replies longer than necessary.
|
- Keep the “one command at a time” queue intact; media fetch/serve must not block other replies longer than necessary.
|
||||||
- Avoid introducing new external services: reuse the existing Tailscale Funnel port to host media for Twilio.
|
- Avoid introducing new external services: reuse the existing Tailscale Funnel port to host media for Twilio.
|
||||||
|
|
||||||
@@ -21,9 +22,12 @@ This document defines how `warelay` should handle sending and replying with imag
|
|||||||
## Provider Behavior
|
## Provider Behavior
|
||||||
### Web (Baileys)
|
### Web (Baileys)
|
||||||
- Input: local file path **or** HTTP(S) URL.
|
- Input: local file path **or** HTTP(S) URL.
|
||||||
- Flow: load into Buffer (max 5 MB), send via `sock.sendMessage(jid, { image: buffer, caption })`.
|
- Flow: load into Buffer, detect media kind, and apply the right payload:
|
||||||
|
- Images: **resize + recompress to JPEG** (max side 2048px, quality step-down) to fit under `inbound.reply.mediaMaxMb` (default 5 MB) but never above the Web hard cap (6 MB).
|
||||||
|
- Audio/voice and video: pass through up to 16 MB; set `ptt: true` for audio to send as a voice note.
|
||||||
|
- Everything else becomes a document with filename, up to 100 MB.
|
||||||
- Caption uses `--message` or `reply.text`; if caption is empty, send media-only.
|
- Caption uses `--message` or `reply.text`; if caption is empty, send media-only.
|
||||||
- Logging: non-verbose shows `↩️`/`✅` with caption; verbose includes `(media, <bytes>B, <ms>ms fetch)`.
|
- Logging: non-verbose shows `↩️`/`✅` with caption; verbose includes `(media, <bytes>B, <ms>ms fetch)` and the local/remote path.
|
||||||
|
|
||||||
### Twilio
|
### Twilio
|
||||||
- Twilio API requires a public HTTPS `MediaUrl`; it will not accept local paths.
|
- Twilio API requires a public HTTPS `MediaUrl`; it will not accept local paths.
|
||||||
@@ -31,7 +35,7 @@ This document defines how `warelay` should handle sending and replying with imag
|
|||||||
- When `--media` is a local path, copy to temp dir (`~/.warelay/media/<uuid>`), serve at `/media/<uuid>` on the existing Express app started for webhook, or spin up a short-lived server on demand for `send`.
|
- When `--media` is a local path, copy to temp dir (`~/.warelay/media/<uuid>`), serve at `/media/<uuid>` on the existing Express app started for webhook, or spin up a short-lived server on demand for `send`.
|
||||||
- `MediaUrl` = `https://<tailnet-host>.ts.net/media/<uuid>`.
|
- `MediaUrl` = `https://<tailnet-host>.ts.net/media/<uuid>`.
|
||||||
- Files auto-removed after TTL (default 2 minutes) or after first successful fetch (best-effort).
|
- Files auto-removed after TTL (default 2 minutes) or after first successful fetch (best-effort).
|
||||||
- Enforce size limit 5 MB; reject early with clear error.
|
- Enforce size limit 5 MB (matches the media host guard); reject early with clear error.
|
||||||
- If `--media` is already an HTTPS URL, pass through unchanged.
|
- If `--media` is already an HTTPS URL, pass through unchanged.
|
||||||
- Fallback: if Funnel is not enabled (or host unknown) and a local path is provided, fail with guidance to run `warelay webhook --ingress tailscale` (or pass a URL instead).
|
- Fallback: if Funnel is not enabled (or host unknown) and a local path is provided, fail with guidance to run `warelay webhook --ingress tailscale` (or pass a URL instead).
|
||||||
|
|
||||||
@@ -55,11 +59,12 @@ This document defines how `warelay` should handle sending and replying with imag
|
|||||||
- For completeness: when inbound Twilio/Web messages include media, download to temp file, expose templating variables:
|
- For completeness: when inbound Twilio/Web messages include media, download to temp file, expose templating variables:
|
||||||
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
|
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
|
||||||
- `{{MediaPath}}` local temp path written before running the command.
|
- `{{MediaPath}}` local temp path written before running the command.
|
||||||
- Size guard: only download if ≤5 MB; else skip and log.
|
- Size guard: only download if ≤5 MB; else skip and log (aligns with the temp media store limit).
|
||||||
|
- Audio/voice notes: if you set `inbound.transcribeAudio.command`, warelay will run that CLI (templated with `{{MediaPath}}`) and replace `Body` with the transcript before continuing the reply flow; verbose logs indicate when transcription runs. The command prompt includes the original media path plus a `Transcript:` section so the model sees both.
|
||||||
|
|
||||||
## Errors & Messaging
|
## Errors & Messaging
|
||||||
- Local path with twilio + Funnel disabled → error: “Twilio media needs a public URL; start `warelay webhook --ingress tailscale` or pass an https:// URL.”
|
- Local path with twilio + Funnel disabled → error: “Twilio media needs a public URL; start `warelay webhook --ingress tailscale` or pass an https:// URL.”
|
||||||
- File too large (>5 MB) → “Media exceeds 5 MB limit; resize or host elsewhere.”
|
- File too large → error mentions the applicable cap (5 MB for Twilio host, 6/16/100 MB for Web image/audio-video/doc respectively).
|
||||||
- Download failure for web provider → “Failed to load media from <source>; skipping send.”
|
- Download failure for web provider → “Failed to load media from <source>; skipping send.”
|
||||||
|
|
||||||
## Tests to Add
|
## Tests to Add
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Web Relay Troubleshooting (Nov 26, 2025)
|
||||||
|
|
||||||
|
## Symptoms & quick fixes
|
||||||
|
- **Stream Errored / Conflict / status 409–515:** WhatsApp closed the socket because another session is active or creds went stale. Run `warelay logout` then `warelay login --provider web` and restart the relay.
|
||||||
|
- **Logged out:** Console prints “session logged out”; re-link with `warelay login --provider web`.
|
||||||
|
- **Repeated retries then exit:** Reconnects are capped (default 12 attempts). Tune with `--web-retries`, `--web-retry-initial`, `--web-retry-max`, or config `web.reconnect`.
|
||||||
|
- **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection.
|
||||||
|
|
||||||
|
## Helpful commands
|
||||||
|
- Start relay web-only: `pnpm warelay relay --provider web --verbose`
|
||||||
|
- Show who is linked: `pnpm warelay relay --provider web --verbose` (first line prints the linked E.164)
|
||||||
|
- Logout (clear creds): `pnpm warelay logout`
|
||||||
|
- Relink: `pnpm warelay login --provider web`
|
||||||
|
- Tail logs (default): `tail -f /tmp/warelay/warelay.log`
|
||||||
|
|
||||||
|
## Reading the logs
|
||||||
|
- `web-reconnect`: close reasons, retry/backoff, max-attempt exit.
|
||||||
|
- `web-heartbeat`: connectionId, messagesHandled, authAgeMs, uptimeMs (every 60s by default).
|
||||||
|
- `web-auto-reply`: inbound/outbound message records with correlation IDs.
|
||||||
|
|
||||||
|
## When to tweak knobs
|
||||||
|
- High churn networks: increase `web.reconnect.maxAttempts` or `--web-retries`.
|
||||||
|
- Slow links: raise `--web-retry-max` to give more headroom before bailing.
|
||||||
|
- Chatty monitors: increase `--web-heartbeat` interval if log volume is high.
|
||||||
|
|
||||||
|
## If it keeps failing
|
||||||
|
1) `warelay logout` → `warelay login --provider web` (fresh QR link).
|
||||||
|
2) Ensure no other device/browser is using the same WA Web session.
|
||||||
|
3) Check WhatsApp mobile app is online and not in low-power mode.
|
||||||
|
4) If status is 515, let the client restart once after pairing (already handled automatically).
|
||||||
|
5) Capture the last `web-reconnect` entry and the status code before escalating.
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "warelay",
|
"name": "warelay",
|
||||||
"version": "0.1.3",
|
"version": "1.1.0",
|
||||||
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
|
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"pino": "^10.1.0",
|
"pino": "^10.1.0",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"twilio": "^5.10.6",
|
"twilio": "^5.10.6",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@whiskeysockets/baileys'
|
||||||
|
- sharp
|
||||||
@@ -3,37 +3,37 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
|
import { parseClaudeJson, parseClaudeJsonText } from "./claude.js";
|
||||||
|
|
||||||
describe("claude JSON parsing", () => {
|
describe("claude JSON parsing", () => {
|
||||||
it("extracts text from single JSON object", () => {
|
it("extracts text from single JSON object", () => {
|
||||||
const out = parseClaudeJsonText('{"text":"hello"}');
|
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||||
expect(out).toBe("hello");
|
expect(out).toBe("hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts from newline-delimited JSON", () => {
|
it("extracts from newline-delimited JSON", () => {
|
||||||
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||||
expect(out).toBe("there");
|
expect(out).toBe("there");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined on invalid JSON", () => {
|
it("returns undefined on invalid JSON", () => {
|
||||||
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
it("extracts text from Claude CLI result field and preserves metadata", () => {
|
||||||
const sample = {
|
const sample = {
|
||||||
type: "result",
|
type: "result",
|
||||||
subtype: "success",
|
subtype: "success",
|
||||||
result: "hello from result field",
|
result: "hello from result field",
|
||||||
duration_ms: 1234,
|
duration_ms: 1234,
|
||||||
usage: { server_tool_use: { tool_a: 2 } },
|
usage: { server_tool_use: { tool_a: 2 } },
|
||||||
};
|
};
|
||||||
const parsed = parseClaudeJson(JSON.stringify(sample));
|
const parsed = parseClaudeJson(JSON.stringify(sample));
|
||||||
expect(parsed?.text).toBe("hello from result field");
|
expect(parsed?.text).toBe("hello from result field");
|
||||||
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
||||||
expect(parsed?.valid).toBe(true);
|
expect(parsed?.valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
it("marks invalid Claude JSON as invalid but still attempts text extraction", () => {
|
||||||
const parsed = parseClaudeJson('{"unexpected":1}');
|
const parsed = parseClaudeJson('{"unexpected":1}');
|
||||||
expect(parsed?.valid).toBe(false);
|
expect(parsed?.valid).toBe(false);
|
||||||
expect(parsed?.text).toBeUndefined();
|
expect(parsed?.text).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+139
-139
@@ -4,159 +4,159 @@ import { z } from "zod";
|
|||||||
// Preferred binary name for Claude CLI invocations.
|
// Preferred binary name for Claude CLI invocations.
|
||||||
export const CLAUDE_BIN = "claude";
|
export const CLAUDE_BIN = "claude";
|
||||||
export const CLAUDE_IDENTITY_PREFIX =
|
export const CLAUDE_IDENTITY_PREFIX =
|
||||||
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters.";
|
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present.";
|
||||||
|
|
||||||
function extractClaudeText(payload: unknown): string | undefined {
|
function extractClaudeText(payload: unknown): string | undefined {
|
||||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||||
if (payload == null) return undefined;
|
if (payload == null) return undefined;
|
||||||
if (typeof payload === "string") return payload;
|
if (typeof payload === "string") return payload;
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
for (const item of payload) {
|
for (const item of payload) {
|
||||||
const found = extractClaudeText(item);
|
const found = extractClaudeText(item);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (typeof payload === "object") {
|
if (typeof payload === "object") {
|
||||||
const obj = payload as Record<string, unknown>;
|
const obj = payload as Record<string, unknown>;
|
||||||
if (typeof obj.result === "string") return obj.result;
|
if (typeof obj.result === "string") return obj.result;
|
||||||
if (typeof obj.text === "string") return obj.text;
|
if (typeof obj.text === "string") return obj.text;
|
||||||
if (typeof obj.completion === "string") return obj.completion;
|
if (typeof obj.completion === "string") return obj.completion;
|
||||||
if (typeof obj.output === "string") return obj.output;
|
if (typeof obj.output === "string") return obj.output;
|
||||||
if (obj.message) {
|
if (obj.message) {
|
||||||
const inner = extractClaudeText(obj.message);
|
const inner = extractClaudeText(obj.message);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj.messages)) {
|
if (Array.isArray(obj.messages)) {
|
||||||
const inner = extractClaudeText(obj.messages);
|
const inner = extractClaudeText(obj.messages);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj.content)) {
|
if (Array.isArray(obj.content)) {
|
||||||
for (const block of obj.content) {
|
for (const block of obj.content) {
|
||||||
if (
|
if (
|
||||||
block &&
|
block &&
|
||||||
typeof block === "object" &&
|
typeof block === "object" &&
|
||||||
(block as { type?: string }).type === "text" &&
|
(block as { type?: string }).type === "text" &&
|
||||||
typeof (block as { text?: unknown }).text === "string"
|
typeof (block as { text?: unknown }).text === "string"
|
||||||
) {
|
) {
|
||||||
return (block as { text: string }).text;
|
return (block as { text: string }).text;
|
||||||
}
|
}
|
||||||
const inner = extractClaudeText(block);
|
const inner = extractClaudeText(block);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClaudeJsonParseResult = {
|
export type ClaudeJsonParseResult = {
|
||||||
text?: string;
|
text?: string;
|
||||||
parsed: unknown;
|
parsed: unknown;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClaudeJsonSchema = z
|
const ClaudeJsonSchema = z
|
||||||
.object({
|
.object({
|
||||||
type: z.string().optional(),
|
type: z.string().optional(),
|
||||||
subtype: z.string().optional(),
|
subtype: z.string().optional(),
|
||||||
is_error: z.boolean().optional(),
|
is_error: z.boolean().optional(),
|
||||||
result: z.string().optional(),
|
result: z.string().optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
completion: z.string().optional(),
|
completion: z.string().optional(),
|
||||||
output: z.string().optional(),
|
output: z.string().optional(),
|
||||||
message: z.any().optional(),
|
message: z.any().optional(),
|
||||||
messages: z.any().optional(),
|
messages: z.any().optional(),
|
||||||
content: z.any().optional(),
|
content: z.any().optional(),
|
||||||
duration_ms: z.number().optional(),
|
duration_ms: z.number().optional(),
|
||||||
duration_api_ms: z.number().optional(),
|
duration_api_ms: z.number().optional(),
|
||||||
num_turns: z.number().optional(),
|
num_turns: z.number().optional(),
|
||||||
session_id: z.string().optional(),
|
session_id: z.string().optional(),
|
||||||
total_cost_usd: z.number().optional(),
|
total_cost_usd: z.number().optional(),
|
||||||
usage: z.record(z.string(), z.any()).optional(),
|
usage: z.record(z.string(), z.any()).optional(),
|
||||||
modelUsage: z.record(z.string(), z.any()).optional(),
|
modelUsage: z.record(z.string(), z.any()).optional(),
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
.refine(
|
.refine(
|
||||||
(obj) =>
|
(obj) =>
|
||||||
typeof obj.result === "string" ||
|
typeof obj.result === "string" ||
|
||||||
typeof obj.text === "string" ||
|
typeof obj.text === "string" ||
|
||||||
typeof obj.completion === "string" ||
|
typeof obj.completion === "string" ||
|
||||||
typeof obj.output === "string" ||
|
typeof obj.output === "string" ||
|
||||||
obj.message !== undefined ||
|
obj.message !== undefined ||
|
||||||
obj.messages !== undefined ||
|
obj.messages !== undefined ||
|
||||||
obj.content !== undefined,
|
obj.content !== undefined,
|
||||||
{ message: "Not a Claude JSON payload" },
|
{ message: "Not a Claude JSON payload" },
|
||||||
);
|
);
|
||||||
|
|
||||||
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
type ClaudeSafeParse = ReturnType<typeof ClaudeJsonSchema.safeParse>;
|
||||||
|
|
||||||
export function parseClaudeJson(
|
export function parseClaudeJson(
|
||||||
raw: string,
|
raw: string,
|
||||||
): ClaudeJsonParseResult | undefined {
|
): ClaudeJsonParseResult | undefined {
|
||||||
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
// Handle a single JSON blob or newline-delimited JSON; return the first parsed payload.
|
||||||
let firstParsed: unknown;
|
let firstParsed: unknown;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
raw,
|
raw,
|
||||||
...raw
|
...raw
|
||||||
.split(/\n+/)
|
.split(/\n+/)
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(candidate);
|
const parsed = JSON.parse(candidate);
|
||||||
if (firstParsed === undefined) firstParsed = parsed;
|
if (firstParsed === undefined) firstParsed = parsed;
|
||||||
let validation: ClaudeSafeParse | { success: false };
|
let validation: ClaudeSafeParse | { success: false };
|
||||||
try {
|
try {
|
||||||
validation = ClaudeJsonSchema.safeParse(parsed);
|
validation = ClaudeJsonSchema.safeParse(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
validation = { success: false } as const;
|
validation = { success: false } as const;
|
||||||
}
|
}
|
||||||
const validated = validation.success ? validation.data : parsed;
|
const validated = validation.success ? validation.data : parsed;
|
||||||
const isLikelyClaude =
|
const isLikelyClaude =
|
||||||
typeof validated === "object" &&
|
typeof validated === "object" &&
|
||||||
validated !== null &&
|
validated !== null &&
|
||||||
("result" in validated ||
|
("result" in validated ||
|
||||||
"text" in validated ||
|
"text" in validated ||
|
||||||
"completion" in validated ||
|
"completion" in validated ||
|
||||||
"output" in validated);
|
"output" in validated);
|
||||||
const text = extractClaudeText(validated);
|
const text = extractClaudeText(validated);
|
||||||
if (text)
|
if (text)
|
||||||
return {
|
return {
|
||||||
parsed: validated,
|
parsed: validated,
|
||||||
text,
|
text,
|
||||||
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
// Treat parse as valid when schema passes or we still see Claude-like shape.
|
||||||
valid: Boolean(validation?.success || isLikelyClaude),
|
valid: Boolean(validation?.success || isLikelyClaude),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors; try next candidate
|
// ignore parse errors; try next candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (firstParsed !== undefined) {
|
if (firstParsed !== undefined) {
|
||||||
let validation: ClaudeSafeParse | { success: false };
|
let validation: ClaudeSafeParse | { success: false };
|
||||||
try {
|
try {
|
||||||
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
validation = ClaudeJsonSchema.safeParse(firstParsed);
|
||||||
} catch {
|
} catch {
|
||||||
validation = { success: false } as const;
|
validation = { success: false } as const;
|
||||||
}
|
}
|
||||||
const validated = validation.success ? validation.data : firstParsed;
|
const validated = validation.success ? validation.data : firstParsed;
|
||||||
const isLikelyClaude =
|
const isLikelyClaude =
|
||||||
typeof validated === "object" &&
|
typeof validated === "object" &&
|
||||||
validated !== null &&
|
validated !== null &&
|
||||||
("result" in validated ||
|
("result" in validated ||
|
||||||
"text" in validated ||
|
"text" in validated ||
|
||||||
"completion" in validated ||
|
"completion" in validated ||
|
||||||
"output" in validated);
|
"output" in validated);
|
||||||
return {
|
return {
|
||||||
parsed: validated,
|
parsed: validated,
|
||||||
text: extractClaudeText(validated),
|
text: extractClaudeText(validated),
|
||||||
valid: Boolean(validation?.success || isLikelyClaude),
|
valid: Boolean(validation?.success || isLikelyClaude),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseClaudeJsonText(raw: string): string | undefined {
|
export function parseClaudeJsonText(raw: string): string | undefined {
|
||||||
const parsed = parseClaudeJson(raw);
|
const parsed = parseClaudeJson(raw);
|
||||||
return parsed?.text;
|
return parsed?.text;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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("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,392 @@
|
|||||||
|
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;
|
||||||
|
if (isClaudeInvocation && finalArgv.length > 0) {
|
||||||
|
const bodyIdx = finalArgv.length - 1;
|
||||||
|
const existingBody = finalArgv[bodyIdx] ?? "";
|
||||||
|
finalArgv = [
|
||||||
|
...finalArgv.slice(0, bodyIdx),
|
||||||
|
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
logVerbose(
|
||||||
|
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+370
-453
@@ -1,497 +1,414 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||||
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
DEFAULT_RESET_TRIGGER,
|
DEFAULT_RESET_TRIGGER,
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { info, isVerbose, logVerbose } from "../globals.js";
|
import { info, isVerbose, logVerbose } from "../globals.js";
|
||||||
import { logError } from "../logger.js";
|
|
||||||
import { ensureMediaHosted } from "../media/host.js";
|
import { ensureMediaHosted } from "../media/host.js";
|
||||||
import { splitMediaFromOutput } from "../media/parse.js";
|
|
||||||
import { enqueueCommand } from "../process/command-queue.js";
|
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import type { TwilioRequester } from "../twilio/types.js";
|
import type { TwilioRequester } from "../twilio/types.js";
|
||||||
import { sendTypingIndicator } from "../twilio/typing.js";
|
import { sendTypingIndicator } from "../twilio/typing.js";
|
||||||
|
import { runCommandReply } from "./command-reply.js";
|
||||||
import {
|
import {
|
||||||
CLAUDE_BIN,
|
applyTemplate,
|
||||||
CLAUDE_IDENTITY_PREFIX,
|
type MsgContext,
|
||||||
type ClaudeJsonParseResult,
|
type TemplateContext,
|
||||||
parseClaudeJson,
|
|
||||||
} from "./claude.js";
|
|
||||||
import {
|
|
||||||
applyTemplate,
|
|
||||||
type MsgContext,
|
|
||||||
type TemplateContext,
|
|
||||||
} from "./templating.js";
|
} from "./templating.js";
|
||||||
|
import { isAudio, transcribeInboundAudio } from "./transcription.js";
|
||||||
|
import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
type GetReplyOptions = {
|
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||||
onReplyStart?: () => Promise<void> | void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
|
||||||
if (!payload || typeof payload !== "object") return undefined;
|
|
||||||
const obj = payload as Record<string, unknown>;
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (typeof obj.duration_ms === "number") {
|
|
||||||
parts.push(`duration=${obj.duration_ms}ms`);
|
|
||||||
}
|
|
||||||
if (typeof obj.duration_api_ms === "number") {
|
|
||||||
parts.push(`api=${obj.duration_api_ms}ms`);
|
|
||||||
}
|
|
||||||
if (typeof obj.num_turns === "number") {
|
|
||||||
parts.push(`turns=${obj.num_turns}`);
|
|
||||||
}
|
|
||||||
if (typeof obj.total_cost_usd === "number") {
|
|
||||||
parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = obj.usage;
|
|
||||||
if (usage && typeof usage === "object") {
|
|
||||||
const serverToolUse = (
|
|
||||||
usage as { server_tool_use?: Record<string, unknown> }
|
|
||||||
).server_tool_use;
|
|
||||||
if (serverToolUse && typeof serverToolUse === "object") {
|
|
||||||
const toolCalls = Object.values(serverToolUse).reduce<number>(
|
|
||||||
(sum, val) => {
|
|
||||||
if (typeof val === "number") return sum + val;
|
|
||||||
return sum;
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelUsage = obj.modelUsage;
|
|
||||||
if (modelUsage && typeof modelUsage === "object") {
|
|
||||||
const models = Object.keys(modelUsage as Record<string, unknown>);
|
|
||||||
if (models.length) {
|
|
||||||
const display =
|
|
||||||
models.length > 2
|
|
||||||
? `${models.slice(0, 2).join(",")}+${models.length - 2}`
|
|
||||||
: models.join(",");
|
|
||||||
parts.push(`models=${display}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length ? parts.join(", ") : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ReplyPayload = {
|
|
||||||
text?: string;
|
|
||||||
mediaUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getReplyFromConfig(
|
export async function getReplyFromConfig(
|
||||||
ctx: MsgContext,
|
ctx: MsgContext,
|
||||||
opts?: GetReplyOptions,
|
opts?: GetReplyOptions,
|
||||||
configOverride?: WarelayConfig,
|
configOverride?: WarelayConfig,
|
||||||
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
|
||||||
): Promise<ReplyPayload | undefined> {
|
): Promise<ReplyPayload | undefined> {
|
||||||
// Choose reply from config: static text or external command stdout.
|
// Choose reply from config: static text or external command stdout.
|
||||||
const cfg = configOverride ?? loadConfig();
|
const cfg = configOverride ?? loadConfig();
|
||||||
const reply = cfg.inbound?.reply;
|
const reply = cfg.inbound?.reply;
|
||||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||||
const timeoutMs = timeoutSeconds * 1000;
|
const timeoutMs = timeoutSeconds * 1000;
|
||||||
let started = false;
|
let started = false;
|
||||||
const onReplyStart = async () => {
|
const triggerTyping = async () => {
|
||||||
if (started) return;
|
await opts?.onReplyStart?.();
|
||||||
started = true;
|
};
|
||||||
await opts?.onReplyStart?.();
|
const onReplyStart = async () => {
|
||||||
};
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
await triggerTyping();
|
||||||
|
};
|
||||||
|
let typingTimer: NodeJS.Timeout | undefined;
|
||||||
|
const typingIntervalMs =
|
||||||
|
reply?.mode === "command"
|
||||||
|
? (reply.typingIntervalSeconds ??
|
||||||
|
reply?.session?.typingIntervalSeconds ??
|
||||||
|
8) * 1000
|
||||||
|
: 0;
|
||||||
|
const cleanupTyping = () => {
|
||||||
|
if (typingTimer) {
|
||||||
|
clearInterval(typingTimer);
|
||||||
|
typingTimer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const startTypingLoop = async () => {
|
||||||
|
if (!opts?.onReplyStart) return;
|
||||||
|
if (typingIntervalMs <= 0) return;
|
||||||
|
if (typingTimer) return;
|
||||||
|
await triggerTyping();
|
||||||
|
typingTimer = setInterval(() => {
|
||||||
|
void triggerTyping();
|
||||||
|
}, typingIntervalMs);
|
||||||
|
};
|
||||||
|
let transcribedText: string | undefined;
|
||||||
|
|
||||||
// Optional session handling (conversation reuse + /new resets)
|
// Optional audio transcription before templating/session handling.
|
||||||
const sessionCfg = reply?.session;
|
if (cfg.inbound?.transcribeAudio && isAudio(ctx.MediaType)) {
|
||||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
|
||||||
? sessionCfg.resetTriggers
|
if (transcribed?.text) {
|
||||||
: [DEFAULT_RESET_TRIGGER];
|
transcribedText = transcribed.text;
|
||||||
const idleMinutes = Math.max(
|
ctx.Body = transcribed.text;
|
||||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
ctx.Transcript = transcribed.text;
|
||||||
1,
|
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||||
);
|
}
|
||||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
}
|
||||||
const storePath = resolveStorePath(sessionCfg?.store);
|
|
||||||
|
|
||||||
let sessionId: string | undefined;
|
// Optional session handling (conversation reuse + /new resets)
|
||||||
let isNewSession = false;
|
const sessionCfg = reply?.session;
|
||||||
let bodyStripped: string | undefined;
|
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||||
|
? sessionCfg.resetTriggers
|
||||||
|
: [DEFAULT_RESET_TRIGGER];
|
||||||
|
const idleMinutes = Math.max(
|
||||||
|
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||||
|
const storePath = resolveStorePath(sessionCfg?.store);
|
||||||
|
let sessionStore: ReturnType<typeof loadSessionStore> | undefined;
|
||||||
|
let sessionKey: string | undefined;
|
||||||
|
|
||||||
if (sessionCfg) {
|
let sessionId: string | undefined;
|
||||||
const trimmedBody = (ctx.Body ?? "").trim();
|
let isNewSession = false;
|
||||||
for (const trigger of resetTriggers) {
|
let bodyStripped: string | undefined;
|
||||||
if (!trigger) continue;
|
let systemSent = false;
|
||||||
if (trimmedBody === trigger) {
|
|
||||||
isNewSession = true;
|
|
||||||
bodyStripped = "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const triggerPrefix = `${trigger} `;
|
|
||||||
if (trimmedBody.startsWith(triggerPrefix)) {
|
|
||||||
isNewSession = true;
|
|
||||||
bodyStripped = trimmedBody.slice(trigger.length).trimStart();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionKey = deriveSessionKey(sessionScope, ctx);
|
if (sessionCfg) {
|
||||||
const store = loadSessionStore(storePath);
|
const trimmedBody = (ctx.Body ?? "").trim();
|
||||||
const entry = store[sessionKey];
|
for (const trigger of resetTriggers) {
|
||||||
const idleMs = idleMinutes * 60_000;
|
if (!trigger) continue;
|
||||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
if (trimmedBody === trigger) {
|
||||||
|
isNewSession = true;
|
||||||
|
bodyStripped = "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const triggerPrefix = `${trigger} `;
|
||||||
|
if (trimmedBody.startsWith(triggerPrefix)) {
|
||||||
|
isNewSession = true;
|
||||||
|
bodyStripped = trimmedBody.slice(trigger.length).trimStart();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isNewSession && freshEntry) {
|
sessionKey = deriveSessionKey(sessionScope, ctx);
|
||||||
sessionId = entry.sessionId;
|
sessionStore = loadSessionStore(storePath);
|
||||||
} else {
|
const entry = sessionStore[sessionKey];
|
||||||
sessionId = crypto.randomUUID();
|
const idleMs = idleMinutes * 60_000;
|
||||||
isNewSession = true;
|
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||||
}
|
|
||||||
|
|
||||||
store[sessionKey] = { sessionId, updatedAt: Date.now() };
|
if (!isNewSession && freshEntry) {
|
||||||
await saveSessionStore(storePath, store);
|
sessionId = entry.sessionId;
|
||||||
}
|
systemSent = entry.systemSent ?? false;
|
||||||
|
} else {
|
||||||
|
sessionId = crypto.randomUUID();
|
||||||
|
isNewSession = true;
|
||||||
|
systemSent = false;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionCtx: TemplateContext = {
|
sessionStore[sessionKey] = { sessionId, updatedAt: Date.now(), systemSent };
|
||||||
...ctx,
|
await saveSessionStore(storePath, sessionStore);
|
||||||
BodyStripped: bodyStripped ?? ctx.Body,
|
}
|
||||||
SessionId: sessionId,
|
|
||||||
IsNewSession: isNewSession ? "true" : "false",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional prefix injected before Body for templating/command prompts.
|
const sessionCtx: TemplateContext = {
|
||||||
const bodyPrefix = reply?.bodyPrefix
|
...ctx,
|
||||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
BodyStripped: bodyStripped ?? ctx.Body,
|
||||||
: "";
|
SessionId: sessionId,
|
||||||
const prefixedBody = bodyPrefix
|
IsNewSession: isNewSession ? "true" : "false",
|
||||||
? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}`
|
};
|
||||||
: (sessionCtx.BodyStripped ?? sessionCtx.Body);
|
|
||||||
const mediaNote = ctx.MediaPath?.length
|
|
||||||
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
|
|
||||||
: undefined;
|
|
||||||
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
|
|
||||||
const mediaReplyHint =
|
|
||||||
mediaNote && reply?.mode === "command"
|
|
||||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
|
||||||
: undefined;
|
|
||||||
const commandBody = mediaNote
|
|
||||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
.trim()
|
|
||||||
: prefixedBody;
|
|
||||||
const templatingCtx: TemplateContext = {
|
|
||||||
...sessionCtx,
|
|
||||||
Body: commandBody,
|
|
||||||
BodyStripped: commandBody,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||||
const allowFrom = cfg.inbound?.allowFrom;
|
const allowFrom = cfg.inbound?.allowFrom;
|
||||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||||
if (!allowFrom.includes(from)) {
|
if (!allowFrom.includes(from)) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
||||||
);
|
);
|
||||||
return undefined;
|
cleanupTyping();
|
||||||
}
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!reply) {
|
}
|
||||||
logVerbose("No inbound.reply configured; skipping auto-reply");
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reply.mode === "text" && reply.text) {
|
await startTypingLoop();
|
||||||
await onReplyStart();
|
|
||||||
logVerbose("Using text auto-reply from config");
|
|
||||||
return {
|
|
||||||
text: applyTemplate(reply.text, templatingCtx),
|
|
||||||
mediaUrl: reply.mediaUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reply.mode === "command" && reply.command?.length) {
|
// Optional prefix injected before Body for templating/command prompts.
|
||||||
await onReplyStart();
|
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
|
||||||
let argv = reply.command.map((part) => applyTemplate(part, templatingCtx));
|
const isFirstTurnInSession = isNewSession || !systemSent;
|
||||||
const templatePrefix = reply.template
|
const sessionIntro =
|
||||||
? applyTemplate(reply.template, templatingCtx)
|
isFirstTurnInSession && sessionCfg?.sessionIntro
|
||||||
: "";
|
? applyTemplate(sessionCfg.sessionIntro, sessionCtx)
|
||||||
if (templatePrefix && argv.length > 0) {
|
: "";
|
||||||
argv = [argv[0], templatePrefix, ...argv.slice(1)];
|
const bodyPrefix = reply?.bodyPrefix
|
||||||
}
|
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
||||||
|
: "";
|
||||||
|
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||||
|
const prefixedBodyBase = (() => {
|
||||||
|
let body = baseBody;
|
||||||
|
if (!sendSystemOnce || isFirstTurnInSession) {
|
||||||
|
body = bodyPrefix ? `${bodyPrefix}${body}` : body;
|
||||||
|
}
|
||||||
|
if (sessionIntro) {
|
||||||
|
body = `${sessionIntro}\n\n${body}`;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
})();
|
||||||
|
if (
|
||||||
|
sessionCfg &&
|
||||||
|
sendSystemOnce &&
|
||||||
|
isFirstTurnInSession &&
|
||||||
|
sessionStore &&
|
||||||
|
sessionKey
|
||||||
|
) {
|
||||||
|
sessionStore[sessionKey] = {
|
||||||
|
...(sessionStore[sessionKey] ?? {}),
|
||||||
|
sessionId: sessionId ?? crypto.randomUUID(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
systemSent: true,
|
||||||
|
};
|
||||||
|
await saveSessionStore(storePath, sessionStore);
|
||||||
|
systemSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure Claude commands can emit plain text by forcing --output-format when configured.
|
const prefixedBody =
|
||||||
// We inject the flags only when the user points at the `claude` binary and has opted in via config,
|
transcribedText && reply?.mode === "command"
|
||||||
// so existing custom argv or non-Claude commands remain untouched.
|
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
|
||||||
if (
|
.filter(Boolean)
|
||||||
reply.claudeOutputFormat &&
|
.join("\n\n")
|
||||||
argv.length > 0 &&
|
: prefixedBodyBase;
|
||||||
path.basename(argv[0]) === CLAUDE_BIN
|
const mediaNote = ctx.MediaPath?.length
|
||||||
) {
|
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
|
||||||
const hasOutputFormat = argv.some(
|
: undefined;
|
||||||
(part) =>
|
// For command prompts we prepend the media note so Claude et al. see it; text replies stay clean.
|
||||||
part === "--output-format" || part.startsWith("--output-format="),
|
const mediaReplyHint =
|
||||||
);
|
mediaNote && reply?.mode === "command"
|
||||||
// Keep the final argument as the prompt/body; insert options just before it.
|
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||||
const insertBeforeBody = Math.max(argv.length - 1, 0);
|
: undefined;
|
||||||
if (!hasOutputFormat) {
|
const commandBody = mediaNote
|
||||||
argv = [
|
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||||
...argv.slice(0, insertBeforeBody),
|
.filter(Boolean)
|
||||||
"--output-format",
|
.join("\n")
|
||||||
reply.claudeOutputFormat,
|
.trim()
|
||||||
...argv.slice(insertBeforeBody),
|
: prefixedBody;
|
||||||
];
|
const templatingCtx: TemplateContext = {
|
||||||
}
|
...sessionCtx,
|
||||||
const hasPrintFlag = argv.some(
|
Body: commandBody,
|
||||||
(part) => part === "-p" || part === "--print",
|
BodyStripped: commandBody,
|
||||||
);
|
};
|
||||||
if (!hasPrintFlag) {
|
if (!reply) {
|
||||||
const insertIdx = Math.max(argv.length - 1, 0);
|
logVerbose("No inbound.reply configured; skipping auto-reply");
|
||||||
argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)];
|
cleanupTyping();
|
||||||
}
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject session args if configured (use resume for existing, session-id for new)
|
if (reply.mode === "text" && reply.text) {
|
||||||
if (reply.session) {
|
await onReplyStart();
|
||||||
const sessionArgList = (
|
logVerbose("Using text auto-reply from config");
|
||||||
isNewSession
|
const result = {
|
||||||
? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"])
|
text: applyTemplate(reply.text, templatingCtx),
|
||||||
: (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"])
|
mediaUrl: reply.mediaUrl,
|
||||||
).map((part) => applyTemplate(part, templatingCtx));
|
};
|
||||||
if (sessionArgList.length) {
|
cleanupTyping();
|
||||||
const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true;
|
return result;
|
||||||
const insertAt =
|
}
|
||||||
insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length;
|
|
||||||
argv = [
|
|
||||||
...argv.slice(0, insertAt),
|
|
||||||
...sessionArgList,
|
|
||||||
...argv.slice(insertAt),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let finalArgv = argv;
|
|
||||||
const isClaudeInvocation =
|
|
||||||
finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN;
|
|
||||||
if (isClaudeInvocation && finalArgv.length > 0) {
|
|
||||||
const bodyIdx = finalArgv.length - 1;
|
|
||||||
const existingBody = finalArgv[bodyIdx] ?? "";
|
|
||||||
finalArgv = [
|
|
||||||
...finalArgv.slice(0, bodyIdx),
|
|
||||||
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
logVerbose(
|
|
||||||
`Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`,
|
|
||||||
);
|
|
||||||
const started = Date.now();
|
|
||||||
try {
|
|
||||||
const { stdout, stderr, code, signal, killed } = await enqueueCommand(
|
|
||||||
() => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }),
|
|
||||||
{
|
|
||||||
onWait: (waitMs, queuedAhead) => {
|
|
||||||
if (isVerbose()) {
|
|
||||||
logVerbose(
|
|
||||||
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const rawStdout = stdout.trim();
|
|
||||||
let mediaFromCommand: string | undefined;
|
|
||||||
let trimmed = rawStdout;
|
|
||||||
if (stderr?.trim()) {
|
|
||||||
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
|
|
||||||
}
|
|
||||||
let parsed: ClaudeJsonParseResult | undefined;
|
|
||||||
if (
|
|
||||||
trimmed &&
|
|
||||||
(reply.claudeOutputFormat === "json" || isClaudeInvocation)
|
|
||||||
) {
|
|
||||||
// Claude JSON mode: extract the human text for both logging and reply while keeping metadata.
|
|
||||||
parsed = parseClaudeJson(trimmed);
|
|
||||||
if (parsed?.parsed && isVerbose()) {
|
|
||||||
const summary = summarizeClaudeMetadata(parsed.parsed);
|
|
||||||
if (summary) logVerbose(`Claude JSON meta: ${summary}`);
|
|
||||||
logVerbose(
|
|
||||||
`Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (parsed?.text) {
|
|
||||||
logVerbose(
|
|
||||||
`Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`,
|
|
||||||
);
|
|
||||||
trimmed = parsed.text.trim();
|
|
||||||
} else {
|
|
||||||
logVerbose("Claude JSON parse failed; returning raw stdout");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Run media extraction once on the final human text (post-JSON parse if available).
|
|
||||||
const { text: cleanedText, mediaUrl: mediaFound } =
|
|
||||||
splitMediaFromOutput(trimmed);
|
|
||||||
trimmed = cleanedText;
|
|
||||||
if (mediaFound) {
|
|
||||||
mediaFromCommand = mediaFound;
|
|
||||||
if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`);
|
|
||||||
} else if (isVerbose()) {
|
|
||||||
logVerbose("No MEDIA token extracted from final text");
|
|
||||||
}
|
|
||||||
if (!trimmed && !mediaFromCommand) {
|
|
||||||
const meta = parsed
|
|
||||||
? summarizeClaudeMetadata(parsed.parsed)
|
|
||||||
: undefined;
|
|
||||||
trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`;
|
|
||||||
logVerbose("No text/media produced; injecting fallback notice to user");
|
|
||||||
}
|
|
||||||
logVerbose(
|
|
||||||
`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`,
|
|
||||||
);
|
|
||||||
logVerbose(`Command auto-reply finished in ${Date.now() - started}ms`);
|
|
||||||
if ((code ?? 0) !== 0) {
|
|
||||||
console.error(
|
|
||||||
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (killed && !signal) {
|
|
||||||
console.error(
|
|
||||||
`Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`,
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const mediaUrl = mediaFromCommand ?? reply.mediaUrl;
|
|
||||||
return trimmed || mediaUrl
|
|
||||||
? { text: trimmed || undefined, mediaUrl }
|
|
||||||
: undefined;
|
|
||||||
} catch (err) {
|
|
||||||
const elapsed = Date.now() - started;
|
|
||||||
const anyErr = err as { killed?: boolean; signal?: string };
|
|
||||||
const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL";
|
|
||||||
const errorObj = err as {
|
|
||||||
stdout?: string;
|
|
||||||
stderr?: string;
|
|
||||||
};
|
|
||||||
if (errorObj.stderr?.trim()) {
|
|
||||||
logVerbose(`Command auto-reply stderr: ${errorObj.stderr.trim()}`);
|
|
||||||
}
|
|
||||||
if (timeoutHit) {
|
|
||||||
console.error(
|
|
||||||
`Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logError(
|
|
||||||
`Command auto-reply failed after ${elapsed}ms: ${String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
if (reply && reply.mode === "command" && reply.command?.length) {
|
||||||
|
await onReplyStart();
|
||||||
|
const commandReply = {
|
||||||
|
...reply,
|
||||||
|
command: reply.command,
|
||||||
|
mode: "command" as const,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { payload, meta } = await runCommandReply({
|
||||||
|
reply: commandReply,
|
||||||
|
templatingCtx,
|
||||||
|
sendSystemOnce,
|
||||||
|
isNewSession,
|
||||||
|
isFirstTurnInSession,
|
||||||
|
systemSent,
|
||||||
|
timeoutMs,
|
||||||
|
timeoutSeconds,
|
||||||
|
commandRunner,
|
||||||
|
});
|
||||||
|
if (meta.claudeMeta && isVerbose()) {
|
||||||
|
logVerbose(`Claude JSON meta: ${meta.claudeMeta}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
|
cleanupTyping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTyping();
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TwilioLikeClient = TwilioRequester & {
|
type TwilioLikeClient = TwilioRequester & {
|
||||||
messages: {
|
messages: {
|
||||||
create: (opts: {
|
create: (opts: {
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
body: string;
|
body: string;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function autoReplyIfConfigured(
|
export async function autoReplyIfConfigured(
|
||||||
client: TwilioLikeClient,
|
client: TwilioLikeClient,
|
||||||
message: MessageInstance,
|
message: MessageInstance,
|
||||||
configOverride?: WarelayConfig,
|
configOverride?: WarelayConfig,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
Body: message.body ?? undefined,
|
Body: message.body ?? undefined,
|
||||||
From: message.from ?? undefined,
|
From: message.from ?? undefined,
|
||||||
To: message.to ?? undefined,
|
To: message.to ?? undefined,
|
||||||
MessageSid: message.sid,
|
MessageSid: message.sid,
|
||||||
};
|
};
|
||||||
|
const cfg = configOverride ?? loadConfig();
|
||||||
|
// Attach media hints for transcription/templates if present on Twilio payloads.
|
||||||
|
const mediaUrl = (message as { mediaUrl?: string }).mediaUrl;
|
||||||
|
if (mediaUrl) ctx.MediaUrl = mediaUrl;
|
||||||
|
|
||||||
const replyResult = await getReplyFromConfig(
|
// Optional audio transcription before building reply.
|
||||||
ctx,
|
const mediaField = (message as { media?: unknown }).media;
|
||||||
{
|
const mediaItems = Array.isArray(mediaField) ? mediaField : [];
|
||||||
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
if (cfg.inbound?.transcribeAudio && mediaItems.length) {
|
||||||
},
|
const media = mediaItems[0];
|
||||||
configOverride,
|
const contentType = (media as { contentType?: string }).contentType;
|
||||||
);
|
if (contentType?.startsWith("audio")) {
|
||||||
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) return;
|
const transcribed = await transcribeInboundAudio(cfg, ctx, runtime);
|
||||||
|
if (transcribed?.text) {
|
||||||
|
ctx.Body = transcribed.text;
|
||||||
|
ctx.MediaType = contentType;
|
||||||
|
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const replyFrom = message.to;
|
const replyResult = await getReplyFromConfig(
|
||||||
const replyTo = message.from;
|
ctx,
|
||||||
if (!replyFrom || !replyTo) {
|
{
|
||||||
if (isVerbose())
|
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
||||||
console.error(
|
},
|
||||||
"Skipping auto-reply: missing to/from on inbound message",
|
cfg,
|
||||||
ctx,
|
);
|
||||||
);
|
if (
|
||||||
return;
|
!replyResult ||
|
||||||
}
|
(!replyResult.text &&
|
||||||
|
!replyResult.mediaUrl &&
|
||||||
|
!replyResult.mediaUrls?.length)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
if (replyResult.text) {
|
const replyFrom = message.to;
|
||||||
logVerbose(
|
const replyTo = message.from;
|
||||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
if (!replyFrom || !replyTo) {
|
||||||
);
|
if (isVerbose())
|
||||||
} else {
|
console.error(
|
||||||
logVerbose(
|
"Skipping auto-reply: missing to/from on inbound message",
|
||||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
ctx,
|
||||||
);
|
);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (replyResult.text) {
|
||||||
let mediaUrl = replyResult.mediaUrl;
|
logVerbose(
|
||||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`,
|
||||||
const hosted = await ensureMediaHosted(mediaUrl);
|
);
|
||||||
mediaUrl = hosted.url;
|
} else {
|
||||||
}
|
logVerbose(
|
||||||
await client.messages.create({
|
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
|
||||||
from: replyFrom,
|
);
|
||||||
to: replyTo,
|
}
|
||||||
body: replyResult.text ?? "",
|
|
||||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
try {
|
||||||
});
|
const mediaList = replyResult.mediaUrls?.length
|
||||||
if (isVerbose()) {
|
? replyResult.mediaUrls
|
||||||
console.log(
|
: replyResult.mediaUrl
|
||||||
info(
|
? [replyResult.mediaUrl]
|
||||||
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`,
|
: [];
|
||||||
),
|
|
||||||
);
|
const sendTwilio = async (body: string, media?: string) => {
|
||||||
}
|
let resolvedMedia = media;
|
||||||
} catch (err) {
|
if (resolvedMedia && !/^https?:\/\//i.test(resolvedMedia)) {
|
||||||
const anyErr = err as {
|
const hosted = await ensureMediaHosted(resolvedMedia);
|
||||||
code?: string | number;
|
resolvedMedia = hosted.url;
|
||||||
message?: unknown;
|
}
|
||||||
moreInfo?: unknown;
|
await client.messages.create({
|
||||||
status?: string | number;
|
from: replyFrom,
|
||||||
response?: { body?: unknown };
|
to: replyTo,
|
||||||
};
|
body,
|
||||||
const { code, status } = anyErr;
|
...(resolvedMedia ? { mediaUrl: [resolvedMedia] } : {}),
|
||||||
const msg =
|
});
|
||||||
typeof anyErr?.message === "string"
|
};
|
||||||
? anyErr.message
|
|
||||||
: (anyErr?.message ?? err);
|
if (mediaList.length === 0) {
|
||||||
runtime.error(
|
await sendTwilio(replyResult.text ?? "");
|
||||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
} else {
|
||||||
);
|
// First media with body (if any), then remaining as separate media-only sends.
|
||||||
if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`);
|
await sendTwilio(replyResult.text ?? "", mediaList[0]);
|
||||||
const responseBody = anyErr?.response?.body;
|
for (const extra of mediaList.slice(1)) {
|
||||||
if (responseBody) {
|
await sendTwilio("", extra);
|
||||||
runtime.error("Response body:");
|
}
|
||||||
runtime.error(JSON.stringify(responseBody, null, 2));
|
}
|
||||||
}
|
if (isVerbose()) {
|
||||||
}
|
console.log(
|
||||||
|
info(
|
||||||
|
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const anyErr = err as {
|
||||||
|
code?: string | number;
|
||||||
|
message?: unknown;
|
||||||
|
moreInfo?: unknown;
|
||||||
|
status?: string | number;
|
||||||
|
response?: { body?: unknown };
|
||||||
|
};
|
||||||
|
const { code, status } = anyErr;
|
||||||
|
const msg =
|
||||||
|
typeof anyErr?.message === "string"
|
||||||
|
? anyErr.message
|
||||||
|
: (anyErr?.message ?? err);
|
||||||
|
runtime.error(
|
||||||
|
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
||||||
|
);
|
||||||
|
if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`);
|
||||||
|
const responseBody = anyErr?.response?.body;
|
||||||
|
if (responseBody) {
|
||||||
|
runtime.error("Response body:");
|
||||||
|
runtime.error(JSON.stringify(responseBody, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
export type MsgContext = {
|
export type MsgContext = {
|
||||||
Body?: string;
|
Body?: string;
|
||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
MessageSid?: string;
|
MessageSid?: string;
|
||||||
MediaPath?: string;
|
MediaPath?: string;
|
||||||
MediaUrl?: string;
|
MediaUrl?: string;
|
||||||
MediaType?: string;
|
MediaType?: string;
|
||||||
|
Transcript?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateContext = MsgContext & {
|
export type TemplateContext = MsgContext & {
|
||||||
BodyStripped?: string;
|
BodyStripped?: string;
|
||||||
SessionId?: string;
|
SessionId?: string;
|
||||||
IsNewSession?: string;
|
IsNewSession?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||||
export function applyTemplate(str: string, ctx: TemplateContext) {
|
export function applyTemplate(str: string, ctx: TemplateContext) {
|
||||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||||
const value = (ctx as Record<string, unknown>)[key];
|
const value = (ctx as Record<string, unknown>)[key];
|
||||||
return value == null ? "" : String(value);
|
return value == null ? "" : String(value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { transcribeInboundAudio } from "./transcription.js";
|
||||||
|
|
||||||
|
vi.mock("../globals.js", () => ({
|
||||||
|
isVerbose: () => false,
|
||||||
|
logVerbose: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../process/exec.js", () => ({
|
||||||
|
runExec: vi.fn(async () => ({ stdout: "transcribed text\n" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("transcribeInboundAudio", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloads mediaUrl to temp file and returns transcript", async () => {
|
||||||
|
const tmpBuf = Buffer.from("audio-bytes");
|
||||||
|
const tmpFile = path.join(os.tmpdir(), `warelay-audio-${Date.now()}.ogg`);
|
||||||
|
await fs.writeFile(tmpFile, tmpBuf);
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
arrayBuffer: async () => tmpBuf,
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
// @ts-expect-error override global fetch for test
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
inbound: {
|
||||||
|
transcribeAudio: {
|
||||||
|
command: ["echo", "{{MediaPath}}"],
|
||||||
|
timeoutSeconds: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = { MediaUrl: "https://example.com/audio.ogg" };
|
||||||
|
|
||||||
|
const result = await transcribeInboundAudio(
|
||||||
|
cfg as never,
|
||||||
|
ctx as never,
|
||||||
|
runtime as never,
|
||||||
|
);
|
||||||
|
expect(result?.text).toBe("transcribed text");
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no transcription command", async () => {
|
||||||
|
const res = await transcribeInboundAudio(
|
||||||
|
{ inbound: {} } as never,
|
||||||
|
{} as never,
|
||||||
|
runtime as never,
|
||||||
|
);
|
||||||
|
expect(res).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { WarelayConfig } from "../config/config.js";
|
||||||
|
import { isVerbose, logVerbose } from "../globals.js";
|
||||||
|
import { runExec } from "../process/exec.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { applyTemplate, type MsgContext } from "./templating.js";
|
||||||
|
|
||||||
|
export function isAudio(mediaType?: string | null) {
|
||||||
|
return Boolean(mediaType?.startsWith("audio"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transcribeInboundAudio(
|
||||||
|
cfg: WarelayConfig,
|
||||||
|
ctx: MsgContext,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): Promise<{ text: string } | undefined> {
|
||||||
|
const transcriber = cfg.inbound?.transcribeAudio;
|
||||||
|
if (!transcriber?.command?.length) return undefined;
|
||||||
|
|
||||||
|
const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000);
|
||||||
|
let tmpPath: string | undefined;
|
||||||
|
let mediaPath = ctx.MediaPath;
|
||||||
|
try {
|
||||||
|
if (!mediaPath && ctx.MediaUrl) {
|
||||||
|
const res = await fetch(ctx.MediaUrl);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const arrayBuf = await res.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuf);
|
||||||
|
tmpPath = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`warelay-audio-${crypto.randomUUID()}.ogg`,
|
||||||
|
);
|
||||||
|
await fs.writeFile(tmpPath, buffer);
|
||||||
|
mediaPath = tmpPath;
|
||||||
|
if (isVerbose()) {
|
||||||
|
logVerbose(
|
||||||
|
`Downloaded audio for transcription (${(buffer.length / (1024 * 1024)).toFixed(2)}MB) -> ${tmpPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mediaPath) return undefined;
|
||||||
|
|
||||||
|
const templCtx: MsgContext = { ...ctx, MediaPath: mediaPath };
|
||||||
|
const argv = transcriber.command.map((part) =>
|
||||||
|
applyTemplate(part, templCtx),
|
||||||
|
);
|
||||||
|
if (isVerbose()) {
|
||||||
|
logVerbose(`Transcribing audio via command: ${argv.join(" ")}`);
|
||||||
|
}
|
||||||
|
const { stdout } = await runExec(argv[0], argv.slice(1), {
|
||||||
|
timeoutMs,
|
||||||
|
maxBuffer: 5 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const text = stdout.trim();
|
||||||
|
if (!text) return undefined;
|
||||||
|
return { text };
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(`Audio transcription failed: ${String(err)}`);
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
if (tmpPath) {
|
||||||
|
void fs.unlink(tmpPath).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type GetReplyOptions = {
|
||||||
|
onReplyStart?: () => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplyPayload = {
|
||||||
|
text?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
};
|
||||||
+76
-76
@@ -6,9 +6,9 @@ import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
|
|||||||
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
||||||
import { ensureMediaHosted } from "../media/host.js";
|
import { ensureMediaHosted } from "../media/host.js";
|
||||||
import {
|
import {
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
} from "../providers/web/index.js";
|
} from "../providers/web/index.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { createClient } from "../twilio/client.js";
|
import { createClient } from "../twilio/client.js";
|
||||||
@@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js";
|
|||||||
import { waitForever } from "./wait.js";
|
import { waitForever } from "./wait.js";
|
||||||
|
|
||||||
export type CliDeps = {
|
export type CliDeps = {
|
||||||
sendMessage: typeof sendMessage;
|
sendMessage: typeof sendMessage;
|
||||||
sendMessageWeb: typeof sendMessageWeb;
|
sendMessageWeb: typeof sendMessageWeb;
|
||||||
waitForFinalStatus: typeof waitForFinalStatus;
|
waitForFinalStatus: typeof waitForFinalStatus;
|
||||||
assertProvider: typeof assertProvider;
|
assertProvider: typeof assertProvider;
|
||||||
createClient?: typeof createClient;
|
createClient?: typeof createClient;
|
||||||
monitorTwilio: typeof monitorTwilio;
|
monitorTwilio: typeof monitorTwilio;
|
||||||
listRecentMessages: typeof listRecentMessages;
|
listRecentMessages: typeof listRecentMessages;
|
||||||
ensurePortAvailable: typeof ensurePortAvailable;
|
ensurePortAvailable: typeof ensurePortAvailable;
|
||||||
startWebhook: typeof startWebhook;
|
startWebhook: typeof startWebhook;
|
||||||
waitForever: typeof waitForever;
|
waitForever: typeof waitForever;
|
||||||
ensureBinary: typeof ensureBinary;
|
ensureBinary: typeof ensureBinary;
|
||||||
ensureFunnel: typeof ensureFunnel;
|
ensureFunnel: typeof ensureFunnel;
|
||||||
getTailnetHostname: typeof getTailnetHostname;
|
getTailnetHostname: typeof getTailnetHostname;
|
||||||
readEnv: typeof readEnv;
|
readEnv: typeof readEnv;
|
||||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||||
updateWebhook: typeof updateWebhook;
|
updateWebhook: typeof updateWebhook;
|
||||||
handlePortError: typeof handlePortError;
|
handlePortError: typeof handlePortError;
|
||||||
monitorWebProvider: typeof monitorWebProvider;
|
monitorWebProvider: typeof monitorWebProvider;
|
||||||
resolveTwilioMediaUrl: (
|
resolveTwilioMediaUrl: (
|
||||||
source: string,
|
source: string,
|
||||||
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function monitorTwilio(
|
export async function monitorTwilio(
|
||||||
intervalSeconds: number,
|
intervalSeconds: number,
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
maxIterations = Infinity,
|
maxIterations = Infinity,
|
||||||
) {
|
) {
|
||||||
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
||||||
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
||||||
client: clientOverride,
|
client: clientOverride,
|
||||||
maxIterations,
|
maxIterations,
|
||||||
deps: {
|
deps: {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
},
|
},
|
||||||
runtime: defaultRuntime,
|
runtime: defaultRuntime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultDeps(): CliDeps {
|
export function createDefaultDeps(): CliDeps {
|
||||||
// Default dependency bundle used by CLI commands and tests.
|
// Default dependency bundle used by CLI commands and tests.
|
||||||
return {
|
return {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
waitForFinalStatus,
|
waitForFinalStatus,
|
||||||
assertProvider,
|
assertProvider,
|
||||||
createClient,
|
createClient,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
startWebhook,
|
startWebhook,
|
||||||
waitForever,
|
waitForever,
|
||||||
ensureBinary,
|
ensureBinary,
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
readEnv,
|
readEnv,
|
||||||
findWhatsappSenderSid,
|
findWhatsappSenderSid,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
||||||
if (/^https?:\/\//i.test(source)) return source;
|
if (/^https?:\/\//i.test(source)) return source;
|
||||||
const hosted = await ensureMediaHosted(source, {
|
const hosted = await ensureMediaHosted(source, {
|
||||||
startServer: serveMedia,
|
startServer: serveMedia,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
return hosted.url;
|
return hosted.url;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
||||||
// Log the configured Twilio sender for clarity in CLI output.
|
// Log the configured Twilio sender for clarity in CLI output.
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { logWebSelfId };
|
export { logWebSelfId };
|
||||||
+54
-52
@@ -14,11 +14,11 @@ const waitForever = vi.fn();
|
|||||||
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||||
@@ -27,63 +27,65 @@ vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
|
|||||||
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
||||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||||
vi.mock("../provider-web.js", () => ({
|
vi.mock("../provider-web.js", () => ({
|
||||||
loginWeb,
|
loginWeb,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
}));
|
}));
|
||||||
vi.mock("./deps.js", () => ({
|
vi.mock("./deps.js", () => ({
|
||||||
createDefaultDeps: () => ({ waitForever }),
|
createDefaultDeps: () => ({ waitForever }),
|
||||||
logTwilioFrom,
|
logTwilioFrom,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
}));
|
}));
|
||||||
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
||||||
|
|
||||||
const { buildProgram } = await import("./program.js");
|
const { buildProgram } = await import("./program.js");
|
||||||
|
|
||||||
describe("cli program", () => {
|
describe("cli program", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs send with required options", async () => {
|
it("runs send with required options", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
expect(sendCommand).toHaveBeenCalled();
|
expect(sendCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid relay provider", async () => {
|
it("rejects invalid relay provider", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||||
).rejects.toThrow("exit");
|
).rejects.toThrow("exit");
|
||||||
expect(runtime.error).toHaveBeenCalledWith(
|
expect(runtime.error).toHaveBeenCalledWith(
|
||||||
"--provider must be auto, web, or twilio",
|
"--provider must be auto, web, or twilio",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to twilio when web relay fails", async () => {
|
it("falls back to twilio when web relay fails", async () => {
|
||||||
pickProvider.mockResolvedValue("web");
|
pickProvider.mockResolvedValue("web");
|
||||||
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(
|
await expect(
|
||||||
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
program.parseAsync(
|
||||||
{ from: "user" },
|
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||||
);
|
{ from: "user" },
|
||||||
expect(logWebSelfId).toHaveBeenCalled();
|
),
|
||||||
expect(ensureTwilioEnv).toHaveBeenCalled();
|
).rejects.toThrow("exit");
|
||||||
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
|
expect(logWebSelfId).toHaveBeenCalled();
|
||||||
});
|
expect(ensureTwilioEnv).not.toHaveBeenCalled();
|
||||||
|
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("runs relay tmux attach command", async () => {
|
it("runs relay tmux attach command", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||||
"pnpm warelay relay --verbose",
|
"pnpm warelay relay --verbose",
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+402
-282
@@ -3,333 +3,453 @@ import { Command } from "commander";
|
|||||||
import { sendCommand } from "../commands/send.js";
|
import { sendCommand } from "../commands/send.js";
|
||||||
import { statusCommand } from "../commands/status.js";
|
import { statusCommand } from "../commands/status.js";
|
||||||
import { webhookCommand } from "../commands/webhook.js";
|
import { webhookCommand } from "../commands/webhook.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import { ensureTwilioEnv } from "../env.js";
|
import { ensureTwilioEnv } from "../env.js";
|
||||||
import { danger, info, setVerbose, setYes, warn } from "../globals.js";
|
import { danger, info, setVerbose, setYes } from "../globals.js";
|
||||||
import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js";
|
import {
|
||||||
|
loginWeb,
|
||||||
|
logoutWeb,
|
||||||
|
monitorWebProvider,
|
||||||
|
pickProvider,
|
||||||
|
type WebMonitorTuning,
|
||||||
|
} from "../provider-web.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
|
import { VERSION } from "../version.js";
|
||||||
import {
|
import {
|
||||||
createDefaultDeps,
|
resolveHeartbeatSeconds,
|
||||||
logTwilioFrom,
|
resolveReconnectPolicy,
|
||||||
logWebSelfId,
|
} from "../web/reconnect.js";
|
||||||
monitorTwilio,
|
import {
|
||||||
|
createDefaultDeps,
|
||||||
|
logTwilioFrom,
|
||||||
|
logWebSelfId,
|
||||||
|
monitorTwilio,
|
||||||
} from "./deps.js";
|
} from "./deps.js";
|
||||||
import { spawnRelayTmux } from "./relay_tmux.js";
|
import { spawnRelayTmux } from "./relay_tmux.js";
|
||||||
|
|
||||||
export function buildProgram() {
|
export function buildProgram() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const PROGRAM_VERSION = "0.1.3";
|
const PROGRAM_VERSION = VERSION;
|
||||||
const TAGLINE =
|
const TAGLINE =
|
||||||
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("warelay")
|
.name("warelay")
|
||||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||||
.version(PROGRAM_VERSION);
|
.version(PROGRAM_VERSION);
|
||||||
|
|
||||||
const formatIntroLine = (version: string, rich = true) => {
|
const formatIntroLine = (version: string, rich = true) => {
|
||||||
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
const base = `📡 warelay ${version} — ${TAGLINE}`;
|
||||||
return rich && chalk.level > 0
|
return rich && chalk.level > 0
|
||||||
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
|
||||||
: base;
|
: base;
|
||||||
};
|
};
|
||||||
|
|
||||||
program.configureHelp({
|
program.configureHelp({
|
||||||
optionTerm: (option) => chalk.yellow(option.flags),
|
optionTerm: (option) => chalk.yellow(option.flags),
|
||||||
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
subcommandTerm: (cmd) => chalk.green(cmd.name()),
|
||||||
});
|
});
|
||||||
|
|
||||||
program.configureOutput({
|
program.configureOutput({
|
||||||
writeOut: (str) => {
|
writeOut: (str) => {
|
||||||
const colored = str
|
const colored = str
|
||||||
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
|
||||||
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
|
||||||
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
|
||||||
process.stdout.write(colored);
|
process.stdout.write(colored);
|
||||||
},
|
},
|
||||||
writeErr: (str) => process.stderr.write(str),
|
writeErr: (str) => process.stderr.write(str),
|
||||||
outputError: (str, write) => write(chalk.red(str)),
|
outputError: (str, write) => write(chalk.red(str)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
if (process.argv.includes("-V") || process.argv.includes("--version")) {
|
||||||
console.log(formatIntroLine(PROGRAM_VERSION));
|
console.log(formatIntroLine(PROGRAM_VERSION));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||||
const examples = [
|
const examples = [
|
||||||
[
|
[
|
||||||
"warelay login --verbose",
|
"warelay login --verbose",
|
||||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
'warelay send --to +15551234567 --message "Hi" --provider web --json',
|
||||||
"Send via your web session and print JSON result.",
|
"Send via your web session and print JSON result.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
|
||||||
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
||||||
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"warelay status --limit 10 --lookback 60 --json",
|
"warelay status --limit 10 --lookback 60 --json",
|
||||||
"Show last 10 messages from the past hour as JSON.",
|
"Show last 10 messages from the past hour as JSON.",
|
||||||
],
|
],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const fmtExamples = examples
|
const fmtExamples = examples
|
||||||
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
program.addHelpText(
|
program.addHelpText(
|
||||||
"afterAll",
|
"afterAll",
|
||||||
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("login")
|
.command("login")
|
||||||
.description("Link your personal WhatsApp via QR (web provider)")
|
.description("Link your personal WhatsApp via QR (web provider)")
|
||||||
.option("--verbose", "Verbose connection logs", false)
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
try {
|
try {
|
||||||
await loginWeb(Boolean(opts.verbose));
|
await loginWeb(Boolean(opts.verbose));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("send")
|
.command("logout")
|
||||||
.description("Send a WhatsApp message")
|
.description("Clear cached WhatsApp Web credentials")
|
||||||
.requiredOption(
|
.action(async () => {
|
||||||
"-t, --to <number>",
|
try {
|
||||||
"Recipient number in E.164 (e.g. +15551234567)",
|
await logoutWeb(defaultRuntime);
|
||||||
)
|
} catch (err) {
|
||||||
.requiredOption("-m, --message <text>", "Message body")
|
defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
|
||||||
.option(
|
defaultRuntime.exit(1);
|
||||||
"--media <path-or-url>",
|
}
|
||||||
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
});
|
||||||
)
|
|
||||||
.option(
|
program
|
||||||
"--serve-media",
|
.command("send")
|
||||||
"For Twilio: start a temporary media server if webhook is not running",
|
.description("Send a WhatsApp message")
|
||||||
false,
|
.requiredOption(
|
||||||
)
|
"-t, --to <number>",
|
||||||
.option(
|
"Recipient number in E.164 (e.g. +15551234567)",
|
||||||
"-w, --wait <seconds>",
|
)
|
||||||
"Wait for delivery status (0 to skip)",
|
.requiredOption("-m, --message <text>", "Message body")
|
||||||
"20",
|
.option(
|
||||||
)
|
"--media <path-or-url>",
|
||||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
||||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
)
|
||||||
.option("--dry-run", "Print payload and skip sending", false)
|
.option(
|
||||||
.option("--json", "Output result as JSON", false)
|
"--serve-media",
|
||||||
.option("--verbose", "Verbose logging", false)
|
"For Twilio: start a temporary media server if webhook is not running",
|
||||||
.addHelpText(
|
false,
|
||||||
"after",
|
)
|
||||||
`
|
.option(
|
||||||
|
"-w, --wait <seconds>",
|
||||||
|
"Wait for delivery status (0 to skip)",
|
||||||
|
"20",
|
||||||
|
)
|
||||||
|
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
||||||
|
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||||
|
.option("--dry-run", "Print payload and skip sending", false)
|
||||||
|
.option("--json", "Output result as JSON", false)
|
||||||
|
.option("--verbose", "Verbose logging", false)
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
|
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
|
||||||
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
|
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
|
||||||
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
|
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
|
||||||
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await sendCommand(opts, deps, defaultRuntime);
|
await sendCommand(opts, deps, defaultRuntime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay")
|
.command("relay")
|
||||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||||
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
||||||
.option(
|
.option(
|
||||||
"-l, --lookback <minutes>",
|
"-l, --lookback <minutes>",
|
||||||
"Initial lookback window for twilio mode",
|
"Initial lookback window for twilio mode",
|
||||||
"5",
|
"5",
|
||||||
)
|
)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option(
|
||||||
.addHelpText(
|
"--web-heartbeat <seconds>",
|
||||||
"after",
|
"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("--verbose", "Verbose logging", false)
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay relay # auto: web if logged-in, else twilio poll
|
warelay relay # auto: web if logged-in, else twilio poll
|
||||||
warelay relay --provider web # force personal web session
|
warelay relay --provider web # force personal web session
|
||||||
warelay relay --provider twilio # force twilio poll
|
warelay relay --provider twilio # force twilio poll
|
||||||
warelay relay --provider twilio --interval 2 --lookback 30
|
warelay relay --provider twilio --interval 2 --lookback 30
|
||||||
|
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const providerPref = String(opts.provider ?? "auto");
|
const providerPref = String(opts.provider ?? "auto");
|
||||||
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||||
defaultRuntime.error("--provider must be auto, web, or twilio");
|
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
||||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
const webHeartbeat =
|
||||||
defaultRuntime.error("Interval must be a positive integer");
|
opts.webHeartbeat !== undefined
|
||||||
defaultRuntime.exit(1);
|
? Number.parseInt(String(opts.webHeartbeat), 10)
|
||||||
}
|
: undefined;
|
||||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
const webRetries =
|
||||||
defaultRuntime.error("Lookback must be >= 0 minutes");
|
opts.webRetries !== undefined
|
||||||
defaultRuntime.exit(1);
|
? 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;
|
||||||
|
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;
|
||||||
|
const reconnect: WebMonitorTuning["reconnect"] = {};
|
||||||
|
if (webRetries !== undefined) reconnect.maxAttempts = webRetries;
|
||||||
|
if (webRetryInitial !== undefined) reconnect.initialMs = webRetryInitial;
|
||||||
|
if (webRetryMax !== undefined) reconnect.maxMs = webRetryMax;
|
||||||
|
if (Object.keys(reconnect).length > 0) {
|
||||||
|
webTuning.reconnect = reconnect;
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === "web") {
|
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||||
logWebSelfId(defaultRuntime, true);
|
|
||||||
try {
|
|
||||||
await monitorWebProvider(Boolean(opts.verbose));
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
if (providerPref === "auto") {
|
|
||||||
defaultRuntime.error(
|
|
||||||
warn("Web session unavailable; falling back to twilio."),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureTwilioEnv();
|
if (provider === "web") {
|
||||||
logTwilioFrom();
|
logWebSelfId(defaultRuntime, true);
|
||||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
const cfg = loadConfig();
|
||||||
});
|
const effectiveHeartbeat = resolveHeartbeatSeconds(
|
||||||
|
cfg,
|
||||||
|
webTuning.heartbeatSeconds,
|
||||||
|
);
|
||||||
|
const effectivePolicy = resolveReconnectPolicy(
|
||||||
|
cfg,
|
||||||
|
webTuning.reconnect,
|
||||||
|
);
|
||||||
|
defaultRuntime.log(
|
||||||
|
info(
|
||||||
|
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await monitorWebProvider(
|
||||||
|
Boolean(opts.verbose),
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
defaultRuntime,
|
||||||
|
undefined,
|
||||||
|
webTuning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
`Web relay failed: ${String(err)}. Not falling back; re-link with 'warelay login --provider web'.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
program
|
ensureTwilioEnv();
|
||||||
.command("status")
|
logTwilioFrom();
|
||||||
.description("Show recent WhatsApp messages (sent and received)")
|
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||||
.option("-l, --limit <count>", "Number of messages to show", "20")
|
});
|
||||||
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
|
||||||
.option("--json", "Output JSON instead of text", false)
|
program
|
||||||
.option("--verbose", "Verbose logging", false)
|
.command("status")
|
||||||
.addHelpText(
|
.description("Show recent WhatsApp messages (sent and received)")
|
||||||
"after",
|
.option("-l, --limit <count>", "Number of messages to show", "20")
|
||||||
`
|
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
||||||
|
.option("--json", "Output JSON instead of text", false)
|
||||||
|
.option("--verbose", "Verbose logging", false)
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay status # last 20 msgs in past 4h
|
warelay status # last 20 msgs in past 4h
|
||||||
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
||||||
warelay status --json --limit 50 # machine-readable output`,
|
warelay status --json --limit 50 # machine-readable output`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await statusCommand(opts, deps, defaultRuntime);
|
await statusCommand(opts, deps, defaultRuntime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("webhook")
|
.command("webhook")
|
||||||
.description(
|
.description(
|
||||||
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
||||||
)
|
)
|
||||||
.option("-p, --port <port>", "Port to listen on", "42873")
|
.option("-p, --port <port>", "Port to listen on", "42873")
|
||||||
.option("-r, --reply <text>", "Optional auto-reply text")
|
.option("-r, --reply <text>", "Optional auto-reply text")
|
||||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||||
.option(
|
.option(
|
||||||
"--ingress <mode>",
|
"--ingress <mode>",
|
||||||
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
)
|
)
|
||||||
.option("--verbose", "Log inbound and auto-replies", false)
|
.option("--verbose", "Log inbound and auto-replies", false)
|
||||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||||
.option("--dry-run", "Print planned actions without starting server", false)
|
.option("--dry-run", "Print planned actions without starting server", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
warelay webhook # ingress=tailscale (funnel + Twilio update)
|
warelay webhook # ingress=tailscale (funnel + Twilio update)
|
||||||
warelay webhook --ingress none # local-only server (no funnel / no Twilio update)
|
warelay webhook --ingress none # local-only server (no funnel / no Twilio update)
|
||||||
warelay webhook --port 45000 # pick a high, less-colliding port
|
warelay webhook --port 45000 # pick a high, less-colliding port
|
||||||
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
||||||
)
|
)
|
||||||
// istanbul ignore next
|
// istanbul ignore next
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
setYes(Boolean(opts.yes));
|
setYes(Boolean(opts.yes));
|
||||||
const deps = createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
info("Webhook dry-run complete; no server started."),
|
info("Webhook dry-run complete; no server started."),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log("\n👋 Webhook stopped");
|
console.log("\n👋 Webhook stopped");
|
||||||
defaultRuntime.exit(0);
|
defaultRuntime.exit(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await deps.waitForever();
|
await deps.waitForever();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay:tmux")
|
.command("relay:tmux")
|
||||||
.description(
|
.description(
|
||||||
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
|
||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
const session = await spawnRelayTmux(
|
const session = await spawnRelayTmux(
|
||||||
"pnpm warelay relay --verbose",
|
"pnpm warelay relay --verbose",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
info(
|
info(
|
||||||
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
danger(`Failed to start relay tmux session: ${String(err)}`),
|
danger(`Failed to start relay tmux session: ${String(err)}`),
|
||||||
);
|
);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("relay:tmux:attach")
|
.command("relay:tmux:attach")
|
||||||
.description(
|
.description(
|
||||||
"Attach to the existing warelay-relay tmux session (no restart)",
|
"Attach to the existing warelay-relay tmux session (no restart)",
|
||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
||||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
defaultRuntime.log(info("Attached to warelay-relay session."));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
danger(`Failed to attach to warelay-relay: ${String(err)}`),
|
||||||
);
|
);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
+31
-31
@@ -3,47 +3,47 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { isYes, setVerbose, setYes } from "../globals.js";
|
import { isYes, setVerbose, setYes } from "../globals.js";
|
||||||
|
|
||||||
vi.mock("node:readline/promises", () => {
|
vi.mock("node:readline/promises", () => {
|
||||||
const question = vi.fn<[], Promise<string>>();
|
const question = vi.fn<[], Promise<string>>();
|
||||||
const close = vi.fn();
|
const close = vi.fn();
|
||||||
const createInterface = vi.fn(() => ({ question, close }));
|
const createInterface = vi.fn(() => ({ question, close }));
|
||||||
return { default: { createInterface } };
|
return { default: { createInterface } };
|
||||||
});
|
});
|
||||||
|
|
||||||
type ReadlineMock = {
|
type ReadlineMock = {
|
||||||
default: {
|
default: {
|
||||||
createInterface: () => {
|
createInterface: () => {
|
||||||
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
|
||||||
close: ReturnType<typeof vi.fn>;
|
close: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const { promptYesNo } = await import("./prompt.js");
|
const { promptYesNo } = await import("./prompt.js");
|
||||||
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
const readline = (await import("node:readline/promises")) as ReadlineMock;
|
||||||
|
|
||||||
describe("promptYesNo", () => {
|
describe("promptYesNo", () => {
|
||||||
it("returns true when global --yes is set", async () => {
|
it("returns true when global --yes is set", async () => {
|
||||||
setYes(true);
|
setYes(true);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
const result = await promptYesNo("Continue?");
|
const result = await promptYesNo("Continue?");
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(isYes()).toBe(true);
|
expect(isYes()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks the question and respects default", async () => {
|
it("asks the question and respects default", async () => {
|
||||||
setYes(false);
|
setYes(false);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
const { question: questionMock } = readline.default.createInterface();
|
const { question: questionMock } = readline.default.createInterface();
|
||||||
questionMock.mockResolvedValueOnce("");
|
questionMock.mockResolvedValueOnce("");
|
||||||
const resultDefaultYes = await promptYesNo("Continue?", true);
|
const resultDefaultYes = await promptYesNo("Continue?", true);
|
||||||
expect(resultDefaultYes).toBe(true);
|
expect(resultDefaultYes).toBe(true);
|
||||||
|
|
||||||
questionMock.mockResolvedValueOnce("n");
|
questionMock.mockResolvedValueOnce("n");
|
||||||
const resultNo = await promptYesNo("Continue?", true);
|
const resultNo = await promptYesNo("Continue?", true);
|
||||||
expect(resultNo).toBe(false);
|
expect(resultNo).toBe(false);
|
||||||
|
|
||||||
questionMock.mockResolvedValueOnce("y");
|
questionMock.mockResolvedValueOnce("y");
|
||||||
const resultYes = await promptYesNo("Continue?", false);
|
const resultYes = await promptYesNo("Continue?", false);
|
||||||
expect(resultYes).toBe(true);
|
expect(resultYes).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+13
-13
@@ -4,18 +4,18 @@ import readline from "node:readline/promises";
|
|||||||
import { isVerbose, isYes } from "../globals.js";
|
import { isVerbose, isYes } from "../globals.js";
|
||||||
|
|
||||||
export async function promptYesNo(
|
export async function promptYesNo(
|
||||||
question: string,
|
question: string,
|
||||||
defaultYes = false,
|
defaultYes = false,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
// Simple Y/N prompt honoring global --yes and verbosity flags.
|
||||||
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
|
||||||
if (isYes()) return true;
|
if (isYes()) return true;
|
||||||
const rl = readline.createInterface({ input, output });
|
const rl = readline.createInterface({ input, output });
|
||||||
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
||||||
const answer = (await rl.question(`${question}${suffix}`))
|
const answer = (await rl.question(`${question}${suffix}`))
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
rl.close();
|
rl.close();
|
||||||
if (!answer) return defaultYes;
|
if (!answer) return defaultYes;
|
||||||
return answer.startsWith("y");
|
return answer.startsWith("y");
|
||||||
}
|
}
|
||||||
+27
-26
@@ -2,43 +2,44 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
|
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
|
||||||
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
return {
|
return {
|
||||||
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
||||||
pickProvider: vi.fn().mockResolvedValue("web"),
|
pickProvider: vi.fn().mockResolvedValue("web"),
|
||||||
logWebSelfId: vi.fn(),
|
logWebSelfId: vi.fn(),
|
||||||
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../provider-web.js", () => ({
|
vi.mock("../provider-web.js", () => ({
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../twilio/monitor.js", () => ({
|
vi.mock("../twilio/monitor.js", () => ({
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { buildProgram } from "./program.js";
|
import { buildProgram } from "./program.js";
|
||||||
|
|
||||||
describe("CLI relay command (e2e-ish)", () => {
|
describe("CLI relay command (e2e-ish)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs relay in web mode without crashing", async () => {
|
it("runs relay in web mode without crashing", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
program.exitOverride(); // throw instead of exiting process on error
|
program.exitOverride(); // throw instead of exiting process on error
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
||||||
).resolves.toBeInstanceOf(Object);
|
).resolves.toBeInstanceOf(Object);
|
||||||
|
|
||||||
expect(pickProvider).toHaveBeenCalledWith("web");
|
expect(pickProvider).toHaveBeenCalledWith("web");
|
||||||
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
||||||
expect(monitorWebProvider).toHaveBeenCalledWith(false);
|
expect(monitorWebProvider).toHaveBeenCalledTimes(1);
|
||||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
expect(monitorWebProvider.mock.calls[0][0]).toBe(false);
|
||||||
});
|
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+33
-33
@@ -3,45 +3,45 @@ import { EventEmitter } from "node:events";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("node:child_process", () => {
|
vi.mock("node:child_process", () => {
|
||||||
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
const spawn = vi.fn((_cmd: string, _args: string[]) => {
|
||||||
const proc = new EventEmitter() as EventEmitter & {
|
const proc = new EventEmitter() as EventEmitter & {
|
||||||
kill: ReturnType<typeof vi.fn>;
|
kill: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
proc.emit("exit", 0);
|
proc.emit("exit", 0);
|
||||||
});
|
});
|
||||||
proc.kill = vi.fn();
|
proc.kill = vi.fn();
|
||||||
return proc;
|
return proc;
|
||||||
});
|
});
|
||||||
return { spawn };
|
return { spawn };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
const { spawnRelayTmux } = await import("./relay_tmux.js");
|
||||||
const { spawn } = await import("node:child_process");
|
const { spawn } = await import("node:child_process");
|
||||||
|
|
||||||
describe("spawnRelayTmux", () => {
|
describe("spawnRelayTmux", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kills old session, starts new one, and attaches", async () => {
|
it("kills old session, starts new one, and attaches", async () => {
|
||||||
const session = await spawnRelayTmux("echo hi", true, true);
|
const session = await spawnRelayTmux("echo hi", true, true);
|
||||||
expect(session).toBe("warelay-relay");
|
expect(session).toBe("warelay-relay");
|
||||||
const spawnMock = spawn as unknown as vi.Mock;
|
const spawnMock = spawn as unknown as vi.Mock;
|
||||||
expect(spawnMock.mock.calls.length).toBe(3);
|
expect(spawnMock.mock.calls.length).toBe(3);
|
||||||
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
|
||||||
expect(calls[0][0]).toBe("tmux"); // kill-session
|
expect(calls[0][0]).toBe("tmux"); // kill-session
|
||||||
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
|
||||||
expect(calls[2][1][0]).toBe("attach-session");
|
expect(calls[2][1][0]).toBe("attach-session");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can skip attach", async () => {
|
it("can skip attach", async () => {
|
||||||
await spawnRelayTmux("echo hi", false, true);
|
await spawnRelayTmux("echo hi", false, true);
|
||||||
const spawnMock = spawn as unknown as vi.Mock;
|
const spawnMock = spawn as unknown as vi.Mock;
|
||||||
const hasAttach = spawnMock.mock.calls.some(
|
const hasAttach = spawnMock.mock.calls.some(
|
||||||
(c) =>
|
(c) =>
|
||||||
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
|
||||||
);
|
);
|
||||||
expect(hasAttach).toBe(false);
|
expect(hasAttach).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+38
-38
@@ -3,48 +3,48 @@ import { spawn } from "node:child_process";
|
|||||||
const SESSION = "warelay-relay";
|
const SESSION = "warelay-relay";
|
||||||
|
|
||||||
export async function spawnRelayTmux(
|
export async function spawnRelayTmux(
|
||||||
cmd = "pnpm warelay relay --verbose",
|
cmd = "pnpm warelay relay --verbose",
|
||||||
attach = true,
|
attach = true,
|
||||||
restart = true,
|
restart = true,
|
||||||
) {
|
) {
|
||||||
if (restart) {
|
if (restart) {
|
||||||
await killSession(SESSION);
|
await killSession(SESSION);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: false,
|
shell: false,
|
||||||
});
|
});
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
else reject(new Error(`tmux exited with code ${code}`));
|
else reject(new Error(`tmux exited with code ${code}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attach) {
|
if (attach) {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: false,
|
shell: false,
|
||||||
});
|
});
|
||||||
child.on("error", reject);
|
child.on("error", reject);
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
else reject(new Error(`tmux attach exited with code ${code}`));
|
else reject(new Error(`tmux attach exited with code ${code}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return SESSION;
|
return SESSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function killSession(name: string) {
|
async function killSession(name: string) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const child = spawn("tmux", ["kill-session", "-t", name], {
|
const child = spawn("tmux", ["kill-session", "-t", name], {
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
});
|
});
|
||||||
child.on("exit", () => resolve());
|
child.on("exit", () => resolve());
|
||||||
child.on("error", () => resolve());
|
child.on("error", () => resolve());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+10
-10
@@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { waitForever } from "./wait.js";
|
import { waitForever } from "./wait.js";
|
||||||
|
|
||||||
describe("waitForever", () => {
|
describe("waitForever", () => {
|
||||||
it("creates an unref'ed interval and returns a pending promise", () => {
|
it("creates an unref'ed interval and returns a pending promise", () => {
|
||||||
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
const setIntervalSpy = vi.spyOn(global, "setInterval");
|
||||||
const promise = waitForever();
|
const promise = waitForever();
|
||||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
1_000_000,
|
1_000_000,
|
||||||
);
|
);
|
||||||
expect(promise).toBeInstanceOf(Promise);
|
expect(promise).toBeInstanceOf(Promise);
|
||||||
setIntervalSpy.mockRestore();
|
setIntervalSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+6
-6
@@ -1,8 +1,8 @@
|
|||||||
export function waitForever() {
|
export function waitForever() {
|
||||||
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
// Keep event loop alive via an unref'ed interval plus a pending promise.
|
||||||
const interval = setInterval(() => {}, 1_000_000);
|
const interval = setInterval(() => {}, 1_000_000);
|
||||||
interval.unref();
|
interval.unref();
|
||||||
return new Promise<void>(() => {
|
return new Promise<void>(() => {
|
||||||
/* never resolve */
|
/* never resolve */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+126
-126
@@ -5,141 +5,141 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { sendCommand } from "./send.js";
|
import { sendCommand } from "./send.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseDeps = {
|
const baseDeps = {
|
||||||
assertProvider: vi.fn(),
|
assertProvider: vi.fn(),
|
||||||
sendMessageWeb: vi.fn(),
|
sendMessageWeb: vi.fn(),
|
||||||
resolveTwilioMediaUrl: vi.fn(),
|
resolveTwilioMediaUrl: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
waitForFinalStatus: vi.fn(),
|
waitForFinalStatus: vi.fn(),
|
||||||
} as unknown as CliDeps;
|
} as unknown as CliDeps;
|
||||||
|
|
||||||
describe("sendCommand", () => {
|
describe("sendCommand", () => {
|
||||||
it("validates wait and poll", async () => {
|
it("validates wait and poll", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
sendCommand(
|
sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "-1",
|
wait: "-1",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
},
|
},
|
||||||
baseDeps,
|
baseDeps,
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||||
|
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
sendCommand(
|
sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "0",
|
poll: "0",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
},
|
},
|
||||||
baseDeps,
|
baseDeps,
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Poll must be > 0 seconds");
|
).rejects.toThrow("Poll must be > 0 seconds");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles web dry-run and warns on wait", async () => {
|
it("handles web dry-run and warns on wait", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
sendMessageWeb: vi.fn(),
|
sendMessageWeb: vi.fn(),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "5",
|
wait: "5",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "web",
|
provider: "web",
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
media: "pic.jpg",
|
media: "pic.jpg",
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends via web and outputs JSON", async () => {
|
it("sends via web and outputs JSON", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "1",
|
wait: "1",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "web",
|
provider: "web",
|
||||||
json: true,
|
json: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"provider": "web"'),
|
expect.stringContaining('"provider": "web"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports twilio dry-run", async () => {
|
it("supports twilio dry-run", async () => {
|
||||||
const deps = { ...baseDeps } as CliDeps;
|
const deps = { ...baseDeps } as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends via twilio with media and skips wait when zero", async () => {
|
it("sends via twilio with media and skips wait when zero", async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
...baseDeps,
|
...baseDeps,
|
||||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||||
waitForFinalStatus: vi.fn(),
|
waitForFinalStatus: vi.fn(),
|
||||||
} as CliDeps;
|
} as CliDeps;
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
to: "+1",
|
to: "+1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
wait: "0",
|
wait: "0",
|
||||||
poll: "2",
|
poll: "2",
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
media: "pic.jpg",
|
media: "pic.jpg",
|
||||||
serveMedia: true,
|
serveMedia: true,
|
||||||
json: true,
|
json: true,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||||
serveMedia: true,
|
serveMedia: true,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"provider": "twilio"'),
|
expect.stringContaining('"provider": "twilio"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+99
-99
@@ -4,109 +4,109 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
|
|
||||||
export async function sendCommand(
|
export async function sendCommand(
|
||||||
opts: {
|
opts: {
|
||||||
to: string;
|
to: string;
|
||||||
message: string;
|
message: string;
|
||||||
wait: string;
|
wait: string;
|
||||||
poll: string;
|
poll: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
media?: string;
|
media?: string;
|
||||||
serveMedia?: boolean;
|
serveMedia?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
deps.assertProvider(opts.provider);
|
deps.assertProvider(opts.provider);
|
||||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||||
|
|
||||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||||
throw new Error("Wait must be >= 0 seconds");
|
throw new Error("Wait must be >= 0 seconds");
|
||||||
}
|
}
|
||||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||||
throw new Error("Poll must be > 0 seconds");
|
throw new Error("Poll must be > 0 seconds");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.provider === "web") {
|
if (opts.provider === "web") {
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (waitSeconds !== 0) {
|
if (waitSeconds !== 0) {
|
||||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||||
}
|
}
|
||||||
const res = await deps
|
const res = await deps
|
||||||
.sendMessageWeb(opts.to, opts.message, {
|
.sendMessageWeb(opts.to, opts.message, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: opts.media,
|
mediaUrl: opts.media,
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
provider: "web",
|
provider: "web",
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
messageId: res.messageId,
|
messageId: res.messageId,
|
||||||
mediaUrl: opts.media ?? null,
|
mediaUrl: opts.media ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaUrl: string | undefined;
|
let mediaUrl: string | undefined;
|
||||||
if (opts.media) {
|
if (opts.media) {
|
||||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||||
serveMedia: Boolean(opts.serveMedia),
|
serveMedia: Boolean(opts.serveMedia),
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await deps.sendMessage(
|
const result = await deps.sendMessage(
|
||||||
opts.to,
|
opts.to,
|
||||||
opts.message,
|
opts.message,
|
||||||
{ mediaUrl },
|
{ mediaUrl },
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
provider: "twilio",
|
provider: "twilio",
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
sid: result?.sid ?? null,
|
sid: result?.sid ?? null,
|
||||||
mediaUrl: mediaUrl ?? null,
|
mediaUrl: mediaUrl ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (waitSeconds === 0) return;
|
if (waitSeconds === 0) return;
|
||||||
await deps.waitForFinalStatus(
|
await deps.waitForFinalStatus(
|
||||||
result.client,
|
result.client,
|
||||||
result.sid,
|
result.sid,
|
||||||
waitSeconds,
|
waitSeconds,
|
||||||
pollSeconds,
|
pollSeconds,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+31
-31
@@ -5,46 +5,46 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { statusCommand } from "./status.js";
|
import { statusCommand } from "./status.js";
|
||||||
|
|
||||||
vi.mock("../twilio/messages.js", () => ({
|
vi.mock("../twilio/messages.js", () => ({
|
||||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
listRecentMessages: vi.fn(),
|
listRecentMessages: vi.fn(),
|
||||||
} as unknown as CliDeps;
|
} as unknown as CliDeps;
|
||||||
|
|
||||||
describe("statusCommand", () => {
|
describe("statusCommand", () => {
|
||||||
it("validates limit and lookback", async () => {
|
it("validates limit and lookback", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||||
).rejects.toThrow("limit must be between 1 and 200");
|
).rejects.toThrow("limit must be between 1 and 200");
|
||||||
await expect(
|
await expect(
|
||||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||||
).rejects.toThrow("lookback must be > 0 minutes");
|
).rejects.toThrow("lookback must be > 0 minutes");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints JSON when requested", async () => {
|
it("prints JSON when requested", async () => {
|
||||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||||
await statusCommand(
|
await statusCommand(
|
||||||
{ limit: "5", lookback: "10", json: true },
|
{ limit: "5", lookback: "10", json: true },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
JSON.stringify([{ sid: "1" }], null, 2),
|
JSON.stringify([{ sid: "1" }], null, 2),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints formatted lines otherwise", async () => {
|
it("prints formatted lines otherwise", async () => {
|
||||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+23
-23
@@ -3,29 +3,29 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { formatMessageLine } from "../twilio/messages.js";
|
import { formatMessageLine } from "../twilio/messages.js";
|
||||||
|
|
||||||
export async function statusCommand(
|
export async function statusCommand(
|
||||||
opts: { limit: string; lookback: string; json?: boolean },
|
opts: { limit: string; lookback: string; json?: boolean },
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
const limit = Number.parseInt(opts.limit, 10);
|
const limit = Number.parseInt(opts.limit, 10);
|
||||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||||
throw new Error("limit must be between 1 and 200");
|
throw new Error("limit must be between 1 and 200");
|
||||||
}
|
}
|
||||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||||
throw new Error("lookback must be > 0 minutes");
|
throw new Error("lookback must be > 0 minutes");
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(JSON.stringify(messages, null, 2));
|
runtime.log(JSON.stringify(messages, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
runtime.log("No messages found in the requested window.");
|
runtime.log("No messages found in the requested window.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
runtime.log(formatMessageLine(m));
|
runtime.log(formatMessageLine(m));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+60
-60
@@ -5,72 +5,72 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { upCommand } from "./up.js";
|
import { upCommand } from "./up.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeDeps = (): CliDeps => ({
|
const makeDeps = (): CliDeps => ({
|
||||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||||
readEnv: vi.fn().mockReturnValue({
|
readEnv: vi.fn().mockReturnValue({
|
||||||
whatsappFrom: "whatsapp:+1555",
|
whatsappFrom: "whatsapp:+1555",
|
||||||
whatsappSenderSid: "WW",
|
whatsappSenderSid: "WW",
|
||||||
}),
|
}),
|
||||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("upCommand", () => {
|
describe("upCommand", () => {
|
||||||
it("throws on invalid port", async () => {
|
it("throws on invalid port", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||||
).rejects.toThrow("Port must be between 1 and 65535");
|
).rejects.toThrow("Port must be between 1 and 65535");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("performs dry run and returns mock data", async () => {
|
it("performs dry run and returns mock data", async () => {
|
||||||
runtime.log.mockClear();
|
runtime.log.mockClear();
|
||||||
const result = await upCommand(
|
const result = await upCommand(
|
||||||
{ port: "42873", path: "/cb", dryRun: true },
|
{ port: "42873", path: "/cb", dryRun: true },
|
||||||
makeDeps(),
|
makeDeps(),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
"[dry-run] would enable funnel on port 42873",
|
"[dry-run] would enable funnel on port 42873",
|
||||||
);
|
);
|
||||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||||
expect(result?.senderSid).toBeUndefined();
|
expect(result?.senderSid).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const res = await upCommand(
|
const res = await upCommand(
|
||||||
{ port: "42873", path: "/hook", verbose: true },
|
{ port: "42873", path: "/hook", verbose: true },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||||
"tailscale",
|
"tailscale",
|
||||||
undefined,
|
undefined,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||||
expect(deps.startWebhook).toHaveBeenCalled();
|
expect(deps.startWebhook).toHaveBeenCalled();
|
||||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
"SID123",
|
"SID123",
|
||||||
"https://tailnet-host/hook",
|
"https://tailnet-host/hook",
|
||||||
"POST",
|
"POST",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||||
// waiter is returned to keep the process alive in real use.
|
// waiter is returned to keep the process alive in real use.
|
||||||
expect(typeof res?.waiter).toBe("function");
|
expect(typeof res?.waiter).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+55
-55
@@ -4,65 +4,65 @@ import { retryAsync } from "../infra/retry.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
export async function upCommand(
|
export async function upCommand(
|
||||||
opts: {
|
opts: {
|
||||||
port: string;
|
port: string;
|
||||||
path: string;
|
path: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||||
) {
|
) {
|
||||||
const port = Number.parseInt(opts.port, 10);
|
const port = Number.parseInt(opts.port, 10);
|
||||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||||
throw new Error("Port must be between 1 and 65535");
|
throw new Error("Port must be between 1 and 65535");
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.ensurePortAvailable(port);
|
await deps.ensurePortAvailable(port);
|
||||||
const env = deps.readEnv(runtime);
|
const env = deps.readEnv(runtime);
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
||||||
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
||||||
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
||||||
const publicUrl = `https://dry-run${opts.path}`;
|
const publicUrl = `https://dry-run${opts.path}`;
|
||||||
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
||||||
}
|
}
|
||||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||||
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||||
const host = await deps.getTailnetHostname();
|
const host = await deps.getTailnetHostname();
|
||||||
const publicUrl = `https://${host}${opts.path}`;
|
const publicUrl = `https://${host}${opts.path}`;
|
||||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||||
|
|
||||||
const server = await retryAsync(
|
const server = await retryAsync(
|
||||||
() =>
|
() =>
|
||||||
deps.startWebhook(
|
deps.startWebhook(
|
||||||
port,
|
port,
|
||||||
opts.path,
|
opts.path,
|
||||||
undefined,
|
undefined,
|
||||||
Boolean(opts.verbose),
|
Boolean(opts.verbose),
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
3,
|
3,
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!deps.createClient) {
|
if (!deps.createClient) {
|
||||||
throw new Error("Twilio client dependency missing");
|
throw new Error("Twilio client dependency missing");
|
||||||
}
|
}
|
||||||
const twilioClient = deps.createClient(env);
|
const twilioClient = deps.createClient(env);
|
||||||
const senderSid = await deps.findWhatsappSenderSid(
|
const senderSid = await deps.findWhatsappSenderSid(
|
||||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||||
env.whatsappFrom,
|
env.whatsappFrom,
|
||||||
env.whatsappSenderSid,
|
env.whatsappSenderSid,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||||
|
|
||||||
runtime.log(
|
runtime.log(
|
||||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||||
);
|
);
|
||||||
|
|
||||||
return { server, publicUrl, senderSid, waiter };
|
return { server, publicUrl, senderSid, waiter };
|
||||||
}
|
}
|
||||||
@@ -6,57 +6,57 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { webhookCommand } from "./webhook.js";
|
import { webhookCommand } from "./webhook.js";
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("webhookCommand", () => {
|
describe("webhookCommand", () => {
|
||||||
it("throws on invalid port", async () => {
|
it("throws on invalid port", async () => {
|
||||||
await expect(() =>
|
await expect(() =>
|
||||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||||
).rejects.toThrow("Port must be between 1 and 65535");
|
).rejects.toThrow("Port must be between 1 and 65535");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs dry run instead of starting server", async () => {
|
it("logs dry run instead of starting server", async () => {
|
||||||
runtime.log.mockClear();
|
runtime.log.mockClear();
|
||||||
const res = await webhookCommand(
|
const res = await webhookCommand(
|
||||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res).toBeUndefined();
|
expect(res).toBeUndefined();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
expect(runtime.log).toHaveBeenCalledWith(
|
||||||
"[dry-run] would start webhook on port 42873 path /hook",
|
"[dry-run] would start webhook on port 42873 path /hook",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts webhook when valid", async () => {
|
it("starts webhook when valid", async () => {
|
||||||
const res = await webhookCommand(
|
const res = await webhookCommand(
|
||||||
{
|
{
|
||||||
port: "42873",
|
port: "42873",
|
||||||
path: "/hook",
|
path: "/hook",
|
||||||
reply: "ok",
|
reply: "ok",
|
||||||
verbose: true,
|
verbose: true,
|
||||||
ingress: "none",
|
ingress: "none",
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||||
42873,
|
42873,
|
||||||
"/hook",
|
"/hook",
|
||||||
"ok",
|
"ok",
|
||||||
true,
|
true,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(res).toEqual({ server: true });
|
expect(res).toEqual({ server: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+52
-52
@@ -4,60 +4,60 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { upCommand } from "./up.js";
|
import { upCommand } from "./up.js";
|
||||||
|
|
||||||
export async function webhookCommand(
|
export async function webhookCommand(
|
||||||
opts: {
|
opts: {
|
||||||
port: string;
|
port: string;
|
||||||
path: string;
|
path: string;
|
||||||
reply?: string;
|
reply?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
ingress?: "tailscale" | "none";
|
ingress?: "tailscale" | "none";
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
const port = Number.parseInt(opts.port, 10);
|
const port = Number.parseInt(opts.port, 10);
|
||||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||||
throw new Error("Port must be between 1 and 65535");
|
throw new Error("Port must be between 1 and 65535");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ingress = opts.ingress ?? "tailscale";
|
const ingress = opts.ingress ?? "tailscale";
|
||||||
|
|
||||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||||
if (ingress === "tailscale") {
|
if (ingress === "tailscale") {
|
||||||
const result = await upCommand(
|
const result = await upCommand(
|
||||||
{
|
{
|
||||||
port: opts.port,
|
port: opts.port,
|
||||||
path: opts.path,
|
path: opts.path,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
yes: opts.yes,
|
yes: opts.yes,
|
||||||
dryRun: opts.dryRun,
|
dryRun: opts.dryRun,
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return result.server;
|
return result.server;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local-only webhook (no ingress / no Twilio update).
|
// Local-only webhook (no ingress / no Twilio update).
|
||||||
await deps.ensurePortAvailable(port);
|
await deps.ensurePortAvailable(port);
|
||||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const server = await retryAsync(
|
const server = await retryAsync(
|
||||||
() =>
|
() =>
|
||||||
deps.startWebhook(
|
deps.startWebhook(
|
||||||
port,
|
port,
|
||||||
opts.path,
|
opts.path,
|
||||||
opts.reply,
|
opts.reply,
|
||||||
Boolean(opts.verbose),
|
Boolean(opts.verbose),
|
||||||
runtime,
|
runtime,
|
||||||
),
|
),
|
||||||
3,
|
3,
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
+153
-104
@@ -10,124 +10,173 @@ export type ClaudeOutputFormat = "text" | "json" | "stream-json";
|
|||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
export type SessionConfig = {
|
export type SessionConfig = {
|
||||||
scope?: SessionScope;
|
scope?: SessionScope;
|
||||||
resetTriggers?: string[];
|
resetTriggers?: string[];
|
||||||
idleMinutes?: number;
|
idleMinutes?: number;
|
||||||
store?: string;
|
store?: string;
|
||||||
sessionArgNew?: string[];
|
sessionArgNew?: string[];
|
||||||
sessionArgResume?: string[];
|
sessionArgResume?: string[];
|
||||||
sessionArgBeforeBody?: boolean;
|
sessionArgBeforeBody?: boolean;
|
||||||
|
sendSystemOnce?: boolean;
|
||||||
|
sessionIntro?: string;
|
||||||
|
typingIntervalSeconds?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoggingConfig = {
|
export type LoggingConfig = {
|
||||||
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
|
||||||
file?: string;
|
file?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebReconnectConfig = {
|
||||||
|
initialMs?: number;
|
||||||
|
maxMs?: number;
|
||||||
|
factor?: number;
|
||||||
|
jitter?: number;
|
||||||
|
maxAttempts?: number; // 0 = unlimited
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebConfig = {
|
||||||
|
heartbeatSeconds?: number;
|
||||||
|
reconnect?: WebReconnectConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WarelayConfig = {
|
export type WarelayConfig = {
|
||||||
logging?: LoggingConfig;
|
logging?: LoggingConfig;
|
||||||
inbound?: {
|
inbound?: {
|
||||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
||||||
reply?: {
|
transcribeAudio?: {
|
||||||
mode: ReplyMode;
|
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||||
text?: string; // for mode=text, can contain {{Body}}
|
command: string[];
|
||||||
command?: string[]; // for mode=command, argv with templates
|
timeoutSeconds?: number;
|
||||||
cwd?: string; // working directory for command execution
|
};
|
||||||
template?: string; // prepend template string when building command/prompt
|
reply?: {
|
||||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
mode: ReplyMode;
|
||||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
text?: string; // for mode=text, can contain {{Body}}
|
||||||
mediaUrl?: string; // optional media attachment (path or URL)
|
command?: string[]; // for mode=command, argv with templates
|
||||||
session?: SessionConfig;
|
cwd?: string; // working directory for command execution
|
||||||
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
template?: string; // prepend template string when building command/prompt
|
||||||
};
|
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||||
};
|
bodyPrefix?: string; // optional string prepended to Body before templating
|
||||||
|
mediaUrl?: string; // optional media attachment (path or URL)
|
||||||
|
session?: SessionConfig;
|
||||||
|
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
|
||||||
|
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
|
||||||
|
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
|
||||||
|
};
|
||||||
|
};
|
||||||
|
web?: WebConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
||||||
|
|
||||||
const ReplySchema = z
|
const ReplySchema = z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
command: z.array(z.string()).optional(),
|
command: z.array(z.string()).optional(),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
template: z.string().optional(),
|
template: z.string().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
bodyPrefix: z.string().optional(),
|
bodyPrefix: z.string().optional(),
|
||||||
mediaUrl: z.string().optional(),
|
mediaUrl: z.string().optional(),
|
||||||
session: z
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
.object({
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
scope: z
|
session: z
|
||||||
.union([z.literal("per-sender"), z.literal("global")])
|
.object({
|
||||||
.optional(),
|
scope: z
|
||||||
resetTriggers: z.array(z.string()).optional(),
|
.union([z.literal("per-sender"), z.literal("global")])
|
||||||
idleMinutes: z.number().int().positive().optional(),
|
.optional(),
|
||||||
store: z.string().optional(),
|
resetTriggers: z.array(z.string()).optional(),
|
||||||
sessionArgNew: z.array(z.string()).optional(),
|
idleMinutes: z.number().int().positive().optional(),
|
||||||
sessionArgResume: z.array(z.string()).optional(),
|
store: z.string().optional(),
|
||||||
sessionArgBeforeBody: z.boolean().optional(),
|
sessionArgNew: z.array(z.string()).optional(),
|
||||||
})
|
sessionArgResume: z.array(z.string()).optional(),
|
||||||
.optional(),
|
sessionArgBeforeBody: z.boolean().optional(),
|
||||||
claudeOutputFormat: z
|
sendSystemOnce: z.boolean().optional(),
|
||||||
.union([
|
sessionIntro: z.string().optional(),
|
||||||
z.literal("text"),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
z.literal("json"),
|
})
|
||||||
z.literal("stream-json"),
|
.optional(),
|
||||||
z.undefined(),
|
claudeOutputFormat: z
|
||||||
])
|
.union([
|
||||||
.optional(),
|
z.literal("text"),
|
||||||
})
|
z.literal("json"),
|
||||||
.refine(
|
z.literal("stream-json"),
|
||||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
z.undefined(),
|
||||||
{
|
])
|
||||||
message:
|
.optional(),
|
||||||
"reply.text is required for mode=text; reply.command is required for mode=command",
|
})
|
||||||
},
|
.refine(
|
||||||
);
|
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"reply.text is required for mode=text; reply.command is required for mode=command",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const WarelaySchema = z.object({
|
const WarelaySchema = z.object({
|
||||||
logging: z
|
logging: z
|
||||||
.object({
|
.object({
|
||||||
level: z
|
level: z
|
||||||
.union([
|
.union([
|
||||||
z.literal("silent"),
|
z.literal("silent"),
|
||||||
z.literal("fatal"),
|
z.literal("fatal"),
|
||||||
z.literal("error"),
|
z.literal("error"),
|
||||||
z.literal("warn"),
|
z.literal("warn"),
|
||||||
z.literal("info"),
|
z.literal("info"),
|
||||||
z.literal("debug"),
|
z.literal("debug"),
|
||||||
z.literal("trace"),
|
z.literal("trace"),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
file: z.string().optional(),
|
file: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
inbound: z
|
inbound: z
|
||||||
.object({
|
.object({
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
reply: ReplySchema.optional(),
|
transcribeAudio: z
|
||||||
})
|
.object({
|
||||||
.optional(),
|
command: z.array(z.string()),
|
||||||
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
reply: ReplySchema.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
web: z
|
||||||
|
.object({
|
||||||
|
heartbeatSeconds: z.number().int().positive().optional(),
|
||||||
|
reconnect: z
|
||||||
|
.object({
|
||||||
|
initialMs: z.number().positive().optional(),
|
||||||
|
maxMs: z.number().positive().optional(),
|
||||||
|
factor: z.number().positive().optional(),
|
||||||
|
jitter: z.number().min(0).max(1).optional(),
|
||||||
|
maxAttempts: z.number().int().min(0).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function loadConfig(): WarelayConfig {
|
export function loadConfig(): WarelayConfig {
|
||||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||||
const parsed = JSON5.parse(raw);
|
const parsed = JSON5.parse(raw);
|
||||||
if (typeof parsed !== "object" || parsed === null) return {};
|
if (typeof parsed !== "object" || parsed === null) return {};
|
||||||
const validated = WarelaySchema.safeParse(parsed);
|
const validated = WarelaySchema.safeParse(parsed);
|
||||||
if (!validated.success) {
|
if (!validated.success) {
|
||||||
console.error("Invalid warelay config:");
|
console.error("Invalid warelay config:");
|
||||||
for (const iss of validated.error.issues) {
|
for (const iss of validated.error.issues) {
|
||||||
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
console.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return validated.data as WarelayConfig;
|
return validated.data as WarelayConfig;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-11
@@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { deriveSessionKey } from "./sessions.js";
|
import { deriveSessionKey } from "./sessions.js";
|
||||||
|
|
||||||
describe("sessions", () => {
|
describe("sessions", () => {
|
||||||
it("returns normalized per-sender key", () => {
|
it("returns normalized per-sender key", () => {
|
||||||
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe(
|
||||||
"+1555",
|
"+1555",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to unknown when sender missing", () => {
|
it("falls back to unknown when sender missing", () => {
|
||||||
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("global scope returns global", () => {
|
it("global scope returns global", () => {
|
||||||
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+31
-27
@@ -8,49 +8,53 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js";
|
|||||||
|
|
||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
export type SessionEntry = { sessionId: string; updatedAt: number };
|
export type SessionEntry = {
|
||||||
|
sessionId: string;
|
||||||
|
updatedAt: number;
|
||||||
|
systemSent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||||
export const DEFAULT_IDLE_MINUTES = 60;
|
export const DEFAULT_IDLE_MINUTES = 60;
|
||||||
|
|
||||||
export function resolveStorePath(store?: string) {
|
export function resolveStorePath(store?: string) {
|
||||||
if (!store) return SESSION_STORE_DEFAULT;
|
if (!store) return SESSION_STORE_DEFAULT;
|
||||||
if (store.startsWith("~"))
|
if (store.startsWith("~"))
|
||||||
return path.resolve(store.replace("~", os.homedir()));
|
return path.resolve(store.replace("~", os.homedir()));
|
||||||
return path.resolve(store);
|
return path.resolve(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSessionStore(
|
export function loadSessionStore(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
): Record<string, SessionEntry> {
|
): Record<string, SessionEntry> {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(storePath, "utf-8");
|
const raw = fs.readFileSync(storePath, "utf-8");
|
||||||
const parsed = JSON5.parse(raw);
|
const parsed = JSON5.parse(raw);
|
||||||
if (parsed && typeof parsed === "object") {
|
if (parsed && typeof parsed === "object") {
|
||||||
return parsed as Record<string, SessionEntry>;
|
return parsed as Record<string, SessionEntry>;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing/invalid store; we'll recreate it
|
// ignore missing/invalid store; we'll recreate it
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSessionStore(
|
export async function saveSessionStore(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
store: Record<string, SessionEntry>,
|
store: Record<string, SessionEntry>,
|
||||||
) {
|
) {
|
||||||
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(store, null, 2),
|
JSON.stringify(store, null, 2),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide which session bucket to use (per-sender vs global).
|
// Decide which session bucket to use (per-sender vs global).
|
||||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||||
if (scope === "global") return "global";
|
if (scope === "global") return "global";
|
||||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||||
return from || "unknown";
|
return from || "unknown";
|
||||||
}
|
}
|
||||||
+80
-80
@@ -4,94 +4,94 @@ import { ensureTwilioEnv, readEnv } from "./env.js";
|
|||||||
import type { RuntimeEnv } from "./runtime.js";
|
import type { RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
const baseEnv = {
|
const baseEnv = {
|
||||||
TWILIO_ACCOUNT_SID: "AC123",
|
TWILIO_ACCOUNT_SID: "AC123",
|
||||||
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("env helpers", () => {
|
describe("env helpers", () => {
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
process.env = {};
|
process.env = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
function setEnv(vars: Record<string, string | undefined>) {
|
function setEnv(vars: Record<string, string | undefined>) {
|
||||||
process.env = {};
|
process.env = {};
|
||||||
for (const [k, v] of Object.entries(vars)) {
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
if (v === undefined) delete process.env[k];
|
if (v === undefined) delete process.env[k];
|
||||||
else process.env[k] = v;
|
else process.env[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it("reads env with auth token", () => {
|
it("reads env with auth token", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: "token",
|
TWILIO_AUTH_TOKEN: "token",
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
const cfg = readEnv(runtime);
|
const cfg = readEnv(runtime);
|
||||||
expect(cfg.accountSid).toBe("AC123");
|
expect(cfg.accountSid).toBe("AC123");
|
||||||
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
||||||
if ("authToken" in cfg.auth) {
|
if ("authToken" in cfg.auth) {
|
||||||
expect(cfg.auth.authToken).toBe("token");
|
expect(cfg.auth.authToken).toBe("token");
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Expected auth token");
|
throw new Error("Expected auth token");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads env with API key/secret", () => {
|
it("reads env with API key/secret", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: "key",
|
TWILIO_API_KEY: "key",
|
||||||
TWILIO_API_SECRET: "secret",
|
TWILIO_API_SECRET: "secret",
|
||||||
});
|
});
|
||||||
const cfg = readEnv(runtime);
|
const cfg = readEnv(runtime);
|
||||||
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
||||||
expect(cfg.auth.apiKey).toBe("key");
|
expect(cfg.auth.apiKey).toBe("key");
|
||||||
expect(cfg.auth.apiSecret).toBe("secret");
|
expect(cfg.auth.apiSecret).toBe("secret");
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Expected API key/secret");
|
throw new Error("Expected API key/secret");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast on invalid env", () => {
|
it("fails fast on invalid env", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
TWILIO_ACCOUNT_SID: "",
|
TWILIO_ACCOUNT_SID: "",
|
||||||
TWILIO_WHATSAPP_FROM: "",
|
TWILIO_WHATSAPP_FROM: "",
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => readEnv(runtime)).toThrow("exit");
|
expect(() => readEnv(runtime)).toThrow("exit");
|
||||||
expect(runtime.error).toHaveBeenCalled();
|
expect(runtime.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTwilioEnv passes when token present", () => {
|
it("ensureTwilioEnv passes when token present", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: "token",
|
TWILIO_AUTH_TOKEN: "token",
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTwilioEnv fails when missing auth", () => {
|
it("ensureTwilioEnv fails when missing auth", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
TWILIO_AUTH_TOKEN: undefined,
|
TWILIO_AUTH_TOKEN: undefined,
|
||||||
TWILIO_API_KEY: undefined,
|
TWILIO_API_KEY: undefined,
|
||||||
TWILIO_API_SECRET: undefined,
|
TWILIO_API_SECRET: undefined,
|
||||||
});
|
});
|
||||||
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+86
-86
@@ -4,103 +4,103 @@ import { danger } from "./globals.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
export type AuthMode =
|
export type AuthMode =
|
||||||
| { accountSid: string; authToken: string }
|
| { accountSid: string; authToken: string }
|
||||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
| { accountSid: string; apiKey: string; apiSecret: string };
|
||||||
|
|
||||||
export type EnvConfig = {
|
export type EnvConfig = {
|
||||||
accountSid: string;
|
accountSid: string;
|
||||||
whatsappFrom: string;
|
whatsappFrom: string;
|
||||||
whatsappSenderSid?: string;
|
whatsappSenderSid?: string;
|
||||||
auth: AuthMode;
|
auth: AuthMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EnvSchema = z
|
const EnvSchema = z
|
||||||
.object({
|
.object({
|
||||||
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
||||||
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
||||||
TWILIO_SENDER_SID: z.string().optional(),
|
TWILIO_SENDER_SID: z.string().optional(),
|
||||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||||
TWILIO_API_KEY: z.string().optional(),
|
TWILIO_API_KEY: z.string().optional(),
|
||||||
TWILIO_API_SECRET: z.string().optional(),
|
TWILIO_API_SECRET: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((val, ctx) => {
|
.superRefine((val, ctx) => {
|
||||||
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!val.TWILIO_AUTH_TOKEN &&
|
!val.TWILIO_AUTH_TOKEN &&
|
||||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||||
) {
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message:
|
message:
|
||||||
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
||||||
// Load and validate Twilio auth + sender configuration from env.
|
// Load and validate Twilio auth + sender configuration from env.
|
||||||
const parsed = EnvSchema.safeParse(process.env);
|
const parsed = EnvSchema.safeParse(process.env);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
runtime.error("Invalid environment configuration:");
|
runtime.error("Invalid environment configuration:");
|
||||||
parsed.error.issues.forEach((iss) => {
|
parsed.error.issues.forEach((iss) => {
|
||||||
runtime.error(`- ${iss.message}`);
|
runtime.error(`- ${iss.message}`);
|
||||||
});
|
});
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
TWILIO_ACCOUNT_SID: accountSid,
|
TWILIO_ACCOUNT_SID: accountSid,
|
||||||
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
||||||
TWILIO_SENDER_SID: whatsappSenderSid,
|
TWILIO_SENDER_SID: whatsappSenderSid,
|
||||||
TWILIO_AUTH_TOKEN: authToken,
|
TWILIO_AUTH_TOKEN: authToken,
|
||||||
TWILIO_API_KEY: apiKey,
|
TWILIO_API_KEY: apiKey,
|
||||||
TWILIO_API_SECRET: apiSecret,
|
TWILIO_API_SECRET: apiSecret,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
let auth: AuthMode;
|
let auth: AuthMode;
|
||||||
if (apiKey && apiSecret) {
|
if (apiKey && apiSecret) {
|
||||||
auth = { accountSid, apiKey, apiSecret };
|
auth = { accountSid, apiKey, apiSecret };
|
||||||
} else if (authToken) {
|
} else if (authToken) {
|
||||||
auth = { accountSid, authToken };
|
auth = { accountSid, authToken };
|
||||||
} else {
|
} else {
|
||||||
runtime.error("Missing Twilio auth configuration");
|
runtime.error("Missing Twilio auth configuration");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
throw new Error("unreachable");
|
throw new Error("unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountSid,
|
accountSid,
|
||||||
whatsappFrom,
|
whatsappFrom,
|
||||||
whatsappSenderSid,
|
whatsappSenderSid,
|
||||||
auth,
|
auth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||||
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
||||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||||
const missing = required.filter((k) => !process.env[k]);
|
const missing = required.filter((k) => !process.env[k]);
|
||||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||||
const hasKey = Boolean(
|
const hasKey = Boolean(
|
||||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||||
);
|
);
|
||||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(
|
danger(
|
||||||
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+21
-21
@@ -2,28 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";
|
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";
|
||||||
|
|
||||||
describe("globals", () => {
|
describe("globals", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
setYes(false);
|
setYes(false);
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles verbose flag and logs when enabled", () => {
|
it("toggles verbose flag and logs when enabled", () => {
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
logVerbose("hidden");
|
logVerbose("hidden");
|
||||||
expect(logSpy).not.toHaveBeenCalled();
|
expect(logSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
setVerbose(true);
|
setVerbose(true);
|
||||||
logVerbose("shown");
|
logVerbose("shown");
|
||||||
expect(isVerbose()).toBe(true);
|
expect(isVerbose()).toBe(true);
|
||||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores yes flag", () => {
|
it("stores yes flag", () => {
|
||||||
setYes(true);
|
setYes(true);
|
||||||
expect(isYes()).toBe(true);
|
expect(isYes()).toBe(true);
|
||||||
setYes(false);
|
setYes(false);
|
||||||
expect(isYes()).toBe(false);
|
expect(isYes()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+5
-5
@@ -4,23 +4,23 @@ let globalVerbose = false;
|
|||||||
let globalYes = false;
|
let globalYes = false;
|
||||||
|
|
||||||
export function setVerbose(v: boolean) {
|
export function setVerbose(v: boolean) {
|
||||||
globalVerbose = v;
|
globalVerbose = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVerbose() {
|
export function isVerbose() {
|
||||||
return globalVerbose;
|
return globalVerbose;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logVerbose(message: string) {
|
export function logVerbose(message: string) {
|
||||||
if (globalVerbose) console.log(chalk.gray(message));
|
if (globalVerbose) console.log(chalk.gray(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setYes(v: boolean) {
|
export function setYes(v: boolean) {
|
||||||
globalYes = v;
|
globalYes = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isYes() {
|
export function isYes() {
|
||||||
return globalYes;
|
return globalYes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const success = chalk.green;
|
export const success = chalk.green;
|
||||||
|
|||||||
+112
-112
@@ -6,134 +6,134 @@ import * as providerWeb from "./provider-web.js";
|
|||||||
import { defaultRuntime } from "./runtime.js";
|
import { defaultRuntime } from "./runtime.js";
|
||||||
|
|
||||||
vi.mock("twilio", () => {
|
vi.mock("twilio", () => {
|
||||||
const { factory } = createMockTwilio();
|
const { factory } = createMockTwilio();
|
||||||
return { default: factory };
|
return { default: factory };
|
||||||
});
|
});
|
||||||
|
|
||||||
import * as index from "./index.js";
|
import * as index from "./index.js";
|
||||||
import * as provider from "./provider-web.js";
|
import * as provider from "./provider-web.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
index.program.exitOverride();
|
index.program.exitOverride();
|
||||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CLI commands", () => {
|
describe("CLI commands", () => {
|
||||||
it("exposes login command", () => {
|
it("exposes login command", () => {
|
||||||
const names = index.program.commands.map((c) => c.name());
|
const names = index.program.commands.map((c) => c.name());
|
||||||
expect(names).toContain("login");
|
expect(names).toContain("login");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command routes to web provider", async () => {
|
it("send command routes to web provider", async () => {
|
||||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
[
|
[
|
||||||
"send",
|
"send",
|
||||||
"--to",
|
"--to",
|
||||||
"+1555",
|
"+1555",
|
||||||
"--message",
|
"--message",
|
||||||
"hi",
|
"hi",
|
||||||
"--provider",
|
"--provider",
|
||||||
"web",
|
"web",
|
||||||
"--wait",
|
"--wait",
|
||||||
"0",
|
"0",
|
||||||
],
|
],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(sendWeb).toHaveBeenCalled();
|
expect(sendWeb).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command uses twilio path when provider=twilio", async () => {
|
it("send command uses twilio path when provider=twilio", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||||
expect(wait).not.toHaveBeenCalled();
|
expect(wait).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command supports dry-run and skips sending", async () => {
|
it("send command supports dry-run and skips sending", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||||
expect(wait).not.toHaveBeenCalled();
|
expect(wait).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("send command outputs JSON when requested", async () => {
|
it("send command outputs JSON when requested", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
||||||
const logSpy = vi.spyOn(defaultRuntime, "log");
|
const logSpy = vi.spyOn(defaultRuntime, "log");
|
||||||
await index.program.parseAsync(
|
await index.program.parseAsync(
|
||||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
||||||
{ from: "user" },
|
{ from: "user" },
|
||||||
);
|
);
|
||||||
expect(logSpy).toHaveBeenCalledWith(
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"sid": "SMJSON"'),
|
expect.stringContaining('"sid": "SMJSON"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("login command calls web login", async () => {
|
it("login command calls web login", async () => {
|
||||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||||
await index.program.parseAsync(["login"], { from: "user" });
|
await index.program.parseAsync(["login"], { from: "user" });
|
||||||
expect(spy).toHaveBeenCalled();
|
expect(spy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("status command prints JSON", async () => {
|
it("status command prints JSON", async () => {
|
||||||
const twilio = (await import("twilio")).default;
|
const twilio = (await import("twilio")).default;
|
||||||
twilio._client.messages.list
|
twilio._client.messages.list
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
sid: "1",
|
sid: "1",
|
||||||
status: "delivered",
|
status: "delivered",
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||||
from: "a",
|
from: "a",
|
||||||
to: "b",
|
to: "b",
|
||||||
body: "hi",
|
body: "hi",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
sid: "2",
|
sid: "2",
|
||||||
status: "sent",
|
status: "sent",
|
||||||
direction: "outbound-api",
|
direction: "outbound-api",
|
||||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||||
from: "b",
|
from: "b",
|
||||||
to: "a",
|
to: "a",
|
||||||
body: "yo",
|
body: "yo",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
...defaultRuntime,
|
...defaultRuntime,
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await statusCommand(
|
await statusCommand(
|
||||||
{ limit: "1", lookback: "10", json: true },
|
{ limit: "1", lookback: "10", json: true },
|
||||||
createDefaultDeps(),
|
createDefaultDeps(),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(runtime.log).toHaveBeenCalled();
|
expect(runtime.log).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+1168
-850
File diff suppressed because it is too large.
Load diff
+16
-16
@@ -2,28 +2,28 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js";
|
import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js";
|
||||||
|
|
||||||
describe("normalizeE164", () => {
|
describe("normalizeE164", () => {
|
||||||
it("strips whatsapp prefix and whitespace", () => {
|
it("strips whatsapp prefix and whitespace", () => {
|
||||||
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds plus when missing", () => {
|
it("adds plus when missing", () => {
|
||||||
expect(normalizeE164("1555123")).toBe("+1555123");
|
expect(normalizeE164("1555123")).toBe("+1555123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toWhatsappJid", () => {
|
describe("toWhatsappJid", () => {
|
||||||
it("converts E164 to jid", () => {
|
it("converts E164 to jid", () => {
|
||||||
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assertProvider", () => {
|
describe("assertProvider", () => {
|
||||||
it("accepts valid providers", () => {
|
it("accepts valid providers", () => {
|
||||||
expect(() => assertProvider("twilio")).not.toThrow();
|
expect(() => assertProvider("twilio")).not.toThrow();
|
||||||
expect(() => assertProvider("web")).not.toThrow();
|
expect(() => assertProvider("web")).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on invalid provider", () => {
|
it("throws on invalid provider", () => {
|
||||||
expect(() => assertProvider("invalid" as string)).toThrow();
|
expect(() => assertProvider("invalid" as string)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+69
-69
@@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {
|
import {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
getReplyFromConfig,
|
getReplyFromConfig,
|
||||||
} from "./auto-reply/reply.js";
|
} from "./auto-reply/reply.js";
|
||||||
import { applyTemplate } from "./auto-reply/templating.js";
|
import { applyTemplate } from "./auto-reply/templating.js";
|
||||||
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
|
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
|
||||||
@@ -13,42 +13,42 @@ import { promptYesNo } from "./cli/prompt.js";
|
|||||||
import { waitForever } from "./cli/wait.js";
|
import { waitForever } from "./cli/wait.js";
|
||||||
import { loadConfig } from "./config/config.js";
|
import { loadConfig } from "./config/config.js";
|
||||||
import {
|
import {
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "./config/sessions.js";
|
} from "./config/sessions.js";
|
||||||
import { readEnv } from "./env.js";
|
import { readEnv } from "./env.js";
|
||||||
import { ensureBinary } from "./infra/binaries.js";
|
import { ensureBinary } from "./infra/binaries.js";
|
||||||
import {
|
import {
|
||||||
describePortOwner,
|
describePortOwner,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
} from "./infra/ports.js";
|
} from "./infra/ports.js";
|
||||||
import {
|
import {
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
} from "./infra/tailscale.js";
|
} from "./infra/tailscale.js";
|
||||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||||
import { monitorWebProvider } from "./provider-web.js";
|
import { monitorWebProvider } from "./provider-web.js";
|
||||||
import { createClient } from "./twilio/client.js";
|
import { createClient } from "./twilio/client.js";
|
||||||
import {
|
import {
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
sortByDateDesc,
|
sortByDateDesc,
|
||||||
uniqueBySid,
|
uniqueBySid,
|
||||||
} from "./twilio/messages.js";
|
} from "./twilio/messages.js";
|
||||||
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
||||||
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
||||||
import { sendTypingIndicator } from "./twilio/typing.js";
|
import { sendTypingIndicator } from "./twilio/typing.js";
|
||||||
import {
|
import {
|
||||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||||
updateWebhook as updateWebhookImpl,
|
updateWebhook as updateWebhookImpl,
|
||||||
} from "./twilio/update-webhook.js";
|
} from "./twilio/update-webhook.js";
|
||||||
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
||||||
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
||||||
@@ -66,56 +66,56 @@ const setMessagingServiceWebhook = setMessagingServiceWebhookImpl;
|
|||||||
const updateWebhook = updateWebhookImpl;
|
const updateWebhook = updateWebhookImpl;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
assertProvider,
|
assertProvider,
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
createClient,
|
createClient,
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
describePortOwner,
|
describePortOwner,
|
||||||
ensureBinary,
|
ensureBinary,
|
||||||
ensureFunnel,
|
ensureFunnel,
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
findIncomingNumberSidImpl as findIncomingNumberSid,
|
findIncomingNumberSidImpl as findIncomingNumberSid,
|
||||||
findMessagingServiceSidImpl as findMessagingServiceSid,
|
findMessagingServiceSidImpl as findMessagingServiceSid,
|
||||||
findWhatsappSenderSid,
|
findWhatsappSenderSid,
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
formatTwilioError,
|
formatTwilioError,
|
||||||
getReplyFromConfig,
|
getReplyFromConfig,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
logTwilioSendError,
|
logTwilioSendError,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
monitorTwilio,
|
monitorTwilio,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
promptYesNo,
|
promptYesNo,
|
||||||
createDefaultDeps,
|
createDefaultDeps,
|
||||||
readEnv,
|
readEnv,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
runCommandWithTimeout,
|
runCommandWithTimeout,
|
||||||
runExec,
|
runExec,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendTypingIndicator,
|
sendTypingIndicator,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
sortByDateDesc,
|
sortByDateDesc,
|
||||||
startWebhook,
|
startWebhook,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
uniqueBySid,
|
uniqueBySid,
|
||||||
waitForFinalStatus,
|
waitForFinalStatus,
|
||||||
waitForever,
|
waitForever,
|
||||||
toWhatsappJid,
|
toWhatsappJid,
|
||||||
program,
|
program,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMain =
|
const isMain =
|
||||||
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||||
|
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
program.parseAsync(process.argv);
|
program.parseAsync(process.argv);
|
||||||
}
|
}
|
||||||
+29
-29
@@ -5,34 +5,34 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { ensureBinary } from "./binaries.js";
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
|
||||||
describe("ensureBinary", () => {
|
describe("ensureBinary", () => {
|
||||||
it("passes through when binary exists", async () => {
|
it("passes through when binary exists", async () => {
|
||||||
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
const exec: typeof runExec = vi.fn().mockResolvedValue({
|
||||||
stdout: "",
|
stdout: "",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
});
|
});
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
exit: vi.fn(),
|
exit: vi.fn(),
|
||||||
};
|
};
|
||||||
await ensureBinary("node", exec, runtime);
|
await ensureBinary("node", exec, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
expect(exec).toHaveBeenCalledWith("which", ["node"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs and exits when missing", async () => {
|
it("logs and exits when missing", async () => {
|
||||||
const exec: typeof runExec = vi
|
const exec: typeof runExec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValue(new Error("missing"));
|
.mockRejectedValue(new Error("missing"));
|
||||||
const error = vi.fn();
|
const error = vi.fn();
|
||||||
const exit = vi.fn(() => {
|
const exit = vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
ensureBinary("ghost", exec, { log: vi.fn(), error, exit }),
|
||||||
).rejects.toThrow("exit");
|
).rejects.toThrow("exit");
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
"Missing required binary: ghost. Please install it.",
|
"Missing required binary: ghost. Please install it.",
|
||||||
);
|
);
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
expect(exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,13 +2,13 @@ import { runExec } from "../process/exec.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
export async function ensureBinary(
|
export async function ensureBinary(
|
||||||
name: string,
|
name: string,
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Abort early if a required CLI tool is missing.
|
// Abort early if a required CLI tool is missing.
|
||||||
await exec("which", [name]).catch(() => {
|
await exec("which", [name]).catch(() => {
|
||||||
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
runtime.error(`Missing required binary: ${name}. Please install it.`);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+27
-27
@@ -2,35 +2,35 @@ import net from "node:net";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ensurePortAvailable,
|
ensurePortAvailable,
|
||||||
handlePortError,
|
handlePortError,
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
|
|
||||||
describe("ports helpers", () => {
|
describe("ports helpers", () => {
|
||||||
it("ensurePortAvailable rejects when port busy", async () => {
|
it("ensurePortAvailable rejects when port busy", async () => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
await new Promise((resolve) => server.listen(0, resolve));
|
await new Promise((resolve) => server.listen(0, resolve));
|
||||||
const port = (server.address() as net.AddressInfo).port;
|
const port = (server.address() as net.AddressInfo).port;
|
||||||
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf(
|
||||||
PortInUseError,
|
PortInUseError,
|
||||||
);
|
);
|
||||||
server.close();
|
server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
it("handlePortError exits nicely on EADDRINUSE", async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: vi.fn() as unknown as (code: number) => never,
|
exit: vi.fn() as unknown as (code: number) => never,
|
||||||
};
|
};
|
||||||
await handlePortError(
|
await handlePortError(
|
||||||
{ code: "EADDRINUSE" },
|
{ code: "EADDRINUSE" },
|
||||||
1234,
|
1234,
|
||||||
"context",
|
"context",
|
||||||
runtime,
|
runtime,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
expect(runtime.error).toHaveBeenCalled();
|
expect(runtime.error).toHaveBeenCalled();
|
||||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+82
-82
@@ -5,103 +5,103 @@ import { runExec } from "../process/exec.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
class PortInUseError extends Error {
|
class PortInUseError extends Error {
|
||||||
port: number;
|
port: number;
|
||||||
details?: string;
|
details?: string;
|
||||||
|
|
||||||
constructor(port: number, details?: string) {
|
constructor(port: number, details?: string) {
|
||||||
super(`Port ${port} is already in use.`);
|
super(`Port ${port} is already in use.`);
|
||||||
this.name = "PortInUseError";
|
this.name = "PortInUseError";
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||||
return Boolean(err && typeof err === "object" && "code" in err);
|
return Boolean(err && typeof err === "object" && "code" in err);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function describePortOwner(
|
export async function describePortOwner(
|
||||||
port: number,
|
port: number,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
// Best-effort process info for a listening port (macOS/Linux).
|
// Best-effort process info for a listening port (macOS/Linux).
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runExec("lsof", [
|
const { stdout } = await runExec("lsof", [
|
||||||
"-i",
|
"-i",
|
||||||
`tcp:${port}`,
|
`tcp:${port}`,
|
||||||
"-sTCP:LISTEN",
|
"-sTCP:LISTEN",
|
||||||
"-nP",
|
"-nP",
|
||||||
]);
|
]);
|
||||||
const trimmed = stdout.trim();
|
const trimmed = stdout.trim();
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) return trimmed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`lsof unavailable: ${String(err)}`);
|
logVerbose(`lsof unavailable: ${String(err)}`);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensurePortAvailable(port: number): Promise<void> {
|
export async function ensurePortAvailable(port: number): Promise<void> {
|
||||||
// Detect EADDRINUSE early with a friendly message.
|
// Detect EADDRINUSE early with a friendly message.
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const tester = net
|
const tester = net
|
||||||
.createServer()
|
.createServer()
|
||||||
.once("error", (err) => reject(err))
|
.once("error", (err) => reject(err))
|
||||||
.once("listening", () => {
|
.once("listening", () => {
|
||||||
tester.close(() => resolve());
|
tester.close(() => resolve());
|
||||||
})
|
})
|
||||||
.listen(port);
|
.listen(port);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||||
const details = await describePortOwner(port);
|
const details = await describePortOwner(port);
|
||||||
throw new PortInUseError(port, details);
|
throw new PortInUseError(port, details);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePortError(
|
export async function handlePortError(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
port: number,
|
port: number,
|
||||||
context: string,
|
context: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
// Uniform messaging for EADDRINUSE with optional owner details.
|
// Uniform messaging for EADDRINUSE with optional owner details.
|
||||||
if (
|
if (
|
||||||
err instanceof PortInUseError ||
|
err instanceof PortInUseError ||
|
||||||
(isErrno(err) && err.code === "EADDRINUSE")
|
(isErrno(err) && err.code === "EADDRINUSE")
|
||||||
) {
|
) {
|
||||||
const details =
|
const details =
|
||||||
err instanceof PortInUseError
|
err instanceof PortInUseError
|
||||||
? err.details
|
? err.details
|
||||||
: await describePortOwner(port);
|
: await describePortOwner(port);
|
||||||
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
runtime.error(danger(`${context} failed: port ${port} is already in use.`));
|
||||||
if (details) {
|
if (details) {
|
||||||
runtime.error(info("Port listener details:"));
|
runtime.error(info("Port listener details:"));
|
||||||
runtime.error(details);
|
runtime.error(details);
|
||||||
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
warn(
|
warn(
|
||||||
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
"It looks like another warelay instance is already running. Stop it or pick a different port.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
"Resolve by stopping the process using the port or passing --port <free-port>.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
runtime.error(danger(`${context} failed: ${String(err)}`));
|
runtime.error(danger(`${context} failed: ${String(err)}`));
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
const stdout = (err as { stdout?: string })?.stdout;
|
const stdout = (err as { stdout?: string })?.stdout;
|
||||||
const stderr = (err as { stderr?: string })?.stderr;
|
const stderr = (err as { stderr?: string })?.stderr;
|
||||||
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`);
|
||||||
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`);
|
||||||
}
|
}
|
||||||
return runtime.exit(1);
|
return runtime.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { PortInUseError };
|
export { PortInUseError };
|
||||||
+20
-20
@@ -3,26 +3,26 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { retryAsync } from "./retry.js";
|
import { retryAsync } from "./retry.js";
|
||||||
|
|
||||||
describe("retryAsync", () => {
|
describe("retryAsync", () => {
|
||||||
it("returns on first success", async () => {
|
it("returns on first success", async () => {
|
||||||
const fn = vi.fn().mockResolvedValue("ok");
|
const fn = vi.fn().mockResolvedValue("ok");
|
||||||
const result = await retryAsync(fn, 3, 10);
|
const result = await retryAsync(fn, 3, 10);
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
expect(fn).toHaveBeenCalledTimes(1);
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries then succeeds", async () => {
|
it("retries then succeeds", async () => {
|
||||||
const fn = vi
|
const fn = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("fail1"))
|
.mockRejectedValueOnce(new Error("fail1"))
|
||||||
.mockResolvedValueOnce("ok");
|
.mockResolvedValueOnce("ok");
|
||||||
const result = await retryAsync(fn, 3, 1);
|
const result = await retryAsync(fn, 3, 1);
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("propagates after exhausting retries", async () => {
|
it("propagates after exhausting retries", async () => {
|
||||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||||
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+15
-15
@@ -1,18 +1,18 @@
|
|||||||
export async function retryAsync<T>(
|
export async function retryAsync<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
attempts = 3,
|
attempts = 3,
|
||||||
initialDelayMs = 300,
|
initialDelayMs = 300,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
for (let i = 0; i < attempts; i += 1) {
|
for (let i = 0; i < attempts; i += 1) {
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
if (i === attempts - 1) break;
|
if (i === attempts - 1) break;
|
||||||
const delay = initialDelayMs * 2 ** i;
|
const delay = initialDelayMs * 2 ** i;
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastErr;
|
throw lastErr;
|
||||||
}
|
}
|
||||||
+51
-51
@@ -1,61 +1,61 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ensureGoInstalled,
|
ensureGoInstalled,
|
||||||
ensureTailscaledInstalled,
|
ensureTailscaledInstalled,
|
||||||
getTailnetHostname,
|
getTailnetHostname,
|
||||||
} from "./tailscale.js";
|
} from "./tailscale.js";
|
||||||
|
|
||||||
describe("tailscale helpers", () => {
|
describe("tailscale helpers", () => {
|
||||||
it("parses DNS name from tailscale status", async () => {
|
it("parses DNS name from tailscale status", async () => {
|
||||||
const exec = vi.fn().mockResolvedValue({
|
const exec = vi.fn().mockResolvedValue({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const host = await getTailnetHostname(exec);
|
const host = await getTailnetHostname(exec);
|
||||||
expect(host).toBe("host.tailnet.ts.net");
|
expect(host).toBe("host.tailnet.ts.net");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to IP when DNS missing", async () => {
|
it("falls back to IP when DNS missing", async () => {
|
||||||
const exec = vi.fn().mockResolvedValue({
|
const exec = vi.fn().mockResolvedValue({
|
||||||
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }),
|
||||||
});
|
});
|
||||||
const host = await getTailnetHostname(exec);
|
const host = await getTailnetHostname(exec);
|
||||||
expect(host).toBe("100.2.2.2");
|
expect(host).toBe("100.2.2.2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
it("ensureGoInstalled installs when missing and user agrees", async () => {
|
||||||
const exec = vi
|
const exec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("no go"))
|
.mockRejectedValueOnce(new Error("no go"))
|
||||||
.mockResolvedValue({}); // brew install go
|
.mockResolvedValue({}); // brew install go
|
||||||
const prompt = vi.fn().mockResolvedValue(true);
|
const prompt = vi.fn().mockResolvedValue(true);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await ensureGoInstalled(exec as never, prompt, runtime);
|
await ensureGoInstalled(exec as never, prompt, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
it("ensureTailscaledInstalled installs when missing and user agrees", async () => {
|
||||||
const exec = vi
|
const exec = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce(new Error("missing"))
|
.mockRejectedValueOnce(new Error("missing"))
|
||||||
.mockResolvedValue({});
|
.mockResolvedValue({});
|
||||||
const prompt = vi.fn().mockResolvedValue(true);
|
const prompt = vi.fn().mockResolvedValue(true);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
exit: ((code: number) => {
|
exit: ((code: number) => {
|
||||||
throw new Error(`exit ${code}`);
|
throw new Error(`exit ${code}`);
|
||||||
}) as (code: number) => never,
|
}) as (code: number) => never,
|
||||||
};
|
};
|
||||||
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
await ensureTailscaledInstalled(exec as never, prompt, runtime);
|
||||||
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+140
-140
@@ -6,158 +6,158 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|||||||
import { ensureBinary } from "./binaries.js";
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
|
||||||
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||||
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
const { stdout } = await exec("tailscale", ["status", "--json"]);
|
||||||
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
|
||||||
const self =
|
const self =
|
||||||
typeof parsed.Self === "object" && parsed.Self !== null
|
typeof parsed.Self === "object" && parsed.Self !== null
|
||||||
? (parsed.Self as Record<string, unknown>)
|
? (parsed.Self as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
const dns =
|
const dns =
|
||||||
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined;
|
||||||
const ips = Array.isArray(self?.TailscaleIPs)
|
const ips = Array.isArray(self?.TailscaleIPs)
|
||||||
? (self.TailscaleIPs as string[])
|
? (self.TailscaleIPs as string[])
|
||||||
: [];
|
: [];
|
||||||
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
||||||
if (ips.length > 0) return ips[0];
|
if (ips.length > 0) return ips[0];
|
||||||
throw new Error("Could not determine Tailscale DNS or IP");
|
throw new Error("Could not determine Tailscale DNS or IP");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureGoInstalled(
|
export async function ensureGoInstalled(
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
// Ensure Go toolchain is present; offer Homebrew install if missing.
|
||||||
const hasGo = await exec("go", ["version"]).then(
|
const hasGo = await exec("go", ["version"]).then(
|
||||||
() => true,
|
() => true,
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
if (hasGo) return;
|
if (hasGo) return;
|
||||||
const install = await prompt(
|
const install = await prompt(
|
||||||
"Go is not installed. Install via Homebrew (brew install go)?",
|
"Go is not installed. Install via Homebrew (brew install go)?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!install) {
|
if (!install) {
|
||||||
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
runtime.error("Go is required to build tailscaled from source. Aborting.");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
logVerbose("Installing Go via Homebrew…");
|
logVerbose("Installing Go via Homebrew…");
|
||||||
await exec("brew", ["install", "go"]);
|
await exec("brew", ["install", "go"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureTailscaledInstalled(
|
export async function ensureTailscaledInstalled(
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
// Ensure tailscaled binary exists; install via Homebrew tailscale if missing.
|
||||||
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
const hasTailscaled = await exec("tailscaled", ["--version"]).then(
|
||||||
() => true,
|
() => true,
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
if (hasTailscaled) return;
|
if (hasTailscaled) return;
|
||||||
|
|
||||||
const install = await prompt(
|
const install = await prompt(
|
||||||
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
"tailscaled not found. Install via Homebrew (tailscale package)?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!install) {
|
if (!install) {
|
||||||
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
runtime.error("tailscaled is required for user-space funnel. Aborting.");
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
logVerbose("Installing tailscaled via Homebrew…");
|
logVerbose("Installing tailscaled via Homebrew…");
|
||||||
await exec("brew", ["install", "tailscale"]);
|
await exec("brew", ["install", "tailscale"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureFunnel(
|
export async function ensureFunnel(
|
||||||
port: number,
|
port: number,
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
) {
|
) {
|
||||||
// Ensure Funnel is enabled and publish the webhook port.
|
// Ensure Funnel is enabled and publish the webhook port.
|
||||||
try {
|
try {
|
||||||
const statusOut = (
|
const statusOut = (
|
||||||
await exec("tailscale", ["funnel", "status", "--json"])
|
await exec("tailscale", ["funnel", "status", "--json"])
|
||||||
).stdout.trim();
|
).stdout.trim();
|
||||||
const parsed = statusOut
|
const parsed = statusOut
|
||||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
if (!parsed || Object.keys(parsed).length === 0) {
|
if (!parsed || Object.keys(parsed).length === 0) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
danger("Tailscale Funnel is not enabled on this tailnet/device."),
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
"macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const proceed = await prompt(
|
const proceed = await prompt(
|
||||||
"Attempt local setup with user-space tailscaled?",
|
"Attempt local setup with user-space tailscaled?",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (!proceed) runtime.exit(1);
|
if (!proceed) runtime.exit(1);
|
||||||
await ensureBinary("brew", exec, runtime);
|
await ensureBinary("brew", exec, runtime);
|
||||||
await ensureGoInstalled(exec, prompt, runtime);
|
await ensureGoInstalled(exec, prompt, runtime);
|
||||||
await ensureTailscaledInstalled(exec, prompt, runtime);
|
await ensureTailscaledInstalled(exec, prompt, runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
logVerbose(`Enabling funnel on port ${port}…`);
|
logVerbose(`Enabling funnel on port ${port}…`);
|
||||||
const { stdout } = await exec(
|
const { stdout } = await exec(
|
||||||
"tailscale",
|
"tailscale",
|
||||||
["funnel", "--yes", "--bg", `${port}`],
|
["funnel", "--yes", "--bg", `${port}`],
|
||||||
{
|
{
|
||||||
maxBuffer: 200_000,
|
maxBuffer: 200_000,
|
||||||
timeoutMs: 15_000,
|
timeoutMs: 15_000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (stdout.trim()) console.log(stdout.trim());
|
if (stdout.trim()) console.log(stdout.trim());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
const errOutput = err as { stdout?: unknown; stderr?: unknown };
|
||||||
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
|
||||||
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
|
||||||
if (stdout.includes("Funnel is not enabled")) {
|
if (stdout.includes("Funnel is not enabled")) {
|
||||||
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
console.error(danger("Funnel is not enabled on this tailnet/device."));
|
||||||
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
const linkMatch = stdout.match(/https?:\/\/\S+/);
|
||||||
if (linkMatch) {
|
if (linkMatch) {
|
||||||
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
console.error(info(`Enable it here: ${linkMatch[0]}`));
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
info(
|
info(
|
||||||
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
"Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
stderr.includes("client version") ||
|
stderr.includes("client version") ||
|
||||||
stdout.includes("client version")
|
stdout.includes("client version")
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
warn(
|
warn(
|
||||||
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
"Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
"Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
|
||||||
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+55
-55
@@ -11,72 +11,72 @@ import { resetLogger, setLoggerOverride } from "./logging.js";
|
|||||||
import type { RuntimeEnv } from "./runtime.js";
|
import type { RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
describe("logger helpers", () => {
|
describe("logger helpers", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetLogger();
|
resetLogger();
|
||||||
setLoggerOverride(null);
|
setLoggerOverride(null);
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats messages through runtime log/error", () => {
|
it("formats messages through runtime log/error", () => {
|
||||||
const log = vi.fn();
|
const log = vi.fn();
|
||||||
const error = vi.fn();
|
const error = vi.fn();
|
||||||
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
const runtime: RuntimeEnv = { log, error, exit: vi.fn() };
|
||||||
|
|
||||||
logInfo("info", runtime);
|
logInfo("info", runtime);
|
||||||
logWarn("warn", runtime);
|
logWarn("warn", runtime);
|
||||||
logSuccess("ok", runtime);
|
logSuccess("ok", runtime);
|
||||||
logError("bad", runtime);
|
logError("bad", runtime);
|
||||||
|
|
||||||
expect(log).toHaveBeenCalledTimes(3);
|
expect(log).toHaveBeenCalledTimes(3);
|
||||||
expect(error).toHaveBeenCalledTimes(1);
|
expect(error).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only logs debug when verbose is enabled", () => {
|
it("only logs debug when verbose is enabled", () => {
|
||||||
const logVerbose = vi.spyOn(console, "log");
|
const logVerbose = vi.spyOn(console, "log");
|
||||||
setVerbose(false);
|
setVerbose(false);
|
||||||
logDebug("quiet");
|
logDebug("quiet");
|
||||||
expect(logVerbose).not.toHaveBeenCalled();
|
expect(logVerbose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
setVerbose(true);
|
setVerbose(true);
|
||||||
logVerbose.mockClear();
|
logVerbose.mockClear();
|
||||||
logDebug("loud");
|
logDebug("loud");
|
||||||
expect(logVerbose).toHaveBeenCalled();
|
expect(logVerbose).toHaveBeenCalled();
|
||||||
logVerbose.mockRestore();
|
logVerbose.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes to configured log file at configured level", () => {
|
it("writes to configured log file at configured level", () => {
|
||||||
const logPath = pathForTest();
|
const logPath = pathForTest();
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
setLoggerOverride({ level: "debug", file: logPath });
|
setLoggerOverride({ level: "debug", file: logPath });
|
||||||
logInfo("hello");
|
logInfo("hello");
|
||||||
logDebug("debug-only");
|
logDebug("debug-only");
|
||||||
const content = fs.readFileSync(logPath, "utf-8");
|
const content = fs.readFileSync(logPath, "utf-8");
|
||||||
expect(content).toContain("hello");
|
expect(content).toContain("hello");
|
||||||
expect(content).toContain("debug-only");
|
expect(content).toContain("debug-only");
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters messages below configured level", () => {
|
it("filters messages below configured level", () => {
|
||||||
const logPath = pathForTest();
|
const logPath = pathForTest();
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
setLoggerOverride({ level: "warn", file: logPath });
|
setLoggerOverride({ level: "warn", file: logPath });
|
||||||
logInfo("info-only");
|
logInfo("info-only");
|
||||||
logWarn("warn-only");
|
logWarn("warn-only");
|
||||||
const content = fs.readFileSync(logPath, "utf-8");
|
const content = fs.readFileSync(logPath, "utf-8");
|
||||||
expect(content).not.toContain("info-only");
|
expect(content).not.toContain("info-only");
|
||||||
expect(content).toContain("warn-only");
|
expect(content).toContain("warn-only");
|
||||||
cleanup(logPath);
|
cleanup(logPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function pathForTest() {
|
function pathForTest() {
|
||||||
return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`);
|
return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup(file: string) {
|
function cleanup(file: string) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(file, { force: true });
|
fs.rmSync(file, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+21
-21
@@ -1,42 +1,42 @@
|
|||||||
import {
|
import {
|
||||||
danger,
|
danger,
|
||||||
info,
|
info,
|
||||||
isVerbose,
|
isVerbose,
|
||||||
logVerbose,
|
logVerbose,
|
||||||
success,
|
success,
|
||||||
warn,
|
warn,
|
||||||
} from "./globals.js";
|
} from "./globals.js";
|
||||||
import { getLogger } from "./logging.js";
|
import { getLogger } from "./logging.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||||
runtime.log(info(message));
|
runtime.log(info(message));
|
||||||
getLogger().info(message);
|
getLogger().info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
|
||||||
runtime.log(warn(message));
|
runtime.log(warn(message));
|
||||||
getLogger().warn(message);
|
getLogger().warn(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logSuccess(
|
export function logSuccess(
|
||||||
message: string,
|
message: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
runtime.log(success(message));
|
runtime.log(success(message));
|
||||||
getLogger().info(message);
|
getLogger().info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logError(
|
export function logError(
|
||||||
message: string,
|
message: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
runtime.error(danger(message));
|
runtime.error(danger(message));
|
||||||
getLogger().error(message);
|
getLogger().error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logDebug(message: string) {
|
export function logDebug(message: string) {
|
||||||
// Always emit to file logger (level-filtered); console only when verbose.
|
// Always emit to file logger (level-filtered); console only when verbose.
|
||||||
getLogger().debug(message);
|
getLogger().debug(message);
|
||||||
if (isVerbose()) logVerbose(message);
|
if (isVerbose()) logVerbose(message);
|
||||||
}
|
}
|
||||||
+52
-52
@@ -10,23 +10,23 @@ const DEFAULT_LOG_DIR = path.join(os.tmpdir(), "warelay");
|
|||||||
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log");
|
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log");
|
||||||
|
|
||||||
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
||||||
"silent",
|
"silent",
|
||||||
"fatal",
|
"fatal",
|
||||||
"error",
|
"error",
|
||||||
"warn",
|
"warn",
|
||||||
"info",
|
"info",
|
||||||
"debug",
|
"debug",
|
||||||
"trace",
|
"trace",
|
||||||
];
|
];
|
||||||
|
|
||||||
export type LoggerSettings = {
|
export type LoggerSettings = {
|
||||||
level?: LevelWithSilent;
|
level?: LevelWithSilent;
|
||||||
file?: string;
|
file?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedSettings = {
|
type ResolvedSettings = {
|
||||||
level: LevelWithSilent;
|
level: LevelWithSilent;
|
||||||
file: string;
|
file: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedLogger: Logger | null = null;
|
let cachedLogger: Logger | null = null;
|
||||||
@@ -34,68 +34,68 @@ let cachedSettings: ResolvedSettings | null = null;
|
|||||||
let overrideSettings: LoggerSettings | null = null;
|
let overrideSettings: LoggerSettings | null = null;
|
||||||
|
|
||||||
function normalizeLevel(level?: string): LevelWithSilent {
|
function normalizeLevel(level?: string): LevelWithSilent {
|
||||||
if (isVerbose()) return "debug";
|
if (isVerbose()) return "debug";
|
||||||
const candidate = level ?? "info";
|
const candidate = level ?? "info";
|
||||||
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
||||||
? (candidate as LevelWithSilent)
|
? (candidate as LevelWithSilent)
|
||||||
: "info";
|
: "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSettings(): ResolvedSettings {
|
function resolveSettings(): ResolvedSettings {
|
||||||
const cfg: WarelayConfig["logging"] | undefined =
|
const cfg: WarelayConfig["logging"] | undefined =
|
||||||
overrideSettings ?? loadConfig().logging;
|
overrideSettings ?? loadConfig().logging;
|
||||||
const level = normalizeLevel(cfg?.level);
|
const level = normalizeLevel(cfg?.level);
|
||||||
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
||||||
return { level, file };
|
return { level, file };
|
||||||
}
|
}
|
||||||
|
|
||||||
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
||||||
if (!a) return true;
|
if (!a) return true;
|
||||||
return a.level !== b.level || a.file !== b.file;
|
return a.level !== b.level || a.file !== b.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLogger(settings: ResolvedSettings): Logger {
|
function buildLogger(settings: ResolvedSettings): Logger {
|
||||||
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
||||||
const destination = pino.destination({
|
const destination = pino.destination({
|
||||||
dest: settings.file,
|
dest: settings.file,
|
||||||
mkdir: true,
|
mkdir: true,
|
||||||
sync: true, // deterministic for tests; log volume is modest.
|
sync: true, // deterministic for tests; log volume is modest.
|
||||||
});
|
});
|
||||||
return pino(
|
return pino(
|
||||||
{
|
{
|
||||||
level: settings.level,
|
level: settings.level,
|
||||||
base: undefined,
|
base: undefined,
|
||||||
timestamp: pino.stdTimeFunctions.isoTime,
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
},
|
},
|
||||||
destination,
|
destination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLogger(): Logger {
|
export function getLogger(): Logger {
|
||||||
const settings = resolveSettings();
|
const settings = resolveSettings();
|
||||||
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
||||||
cachedLogger = buildLogger(settings);
|
cachedLogger = buildLogger(settings);
|
||||||
cachedSettings = settings;
|
cachedSettings = settings;
|
||||||
}
|
}
|
||||||
return cachedLogger;
|
return cachedLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChildLogger(
|
export function getChildLogger(
|
||||||
bindings?: Bindings,
|
bindings?: Bindings,
|
||||||
opts?: { level?: LevelWithSilent },
|
opts?: { level?: LevelWithSilent },
|
||||||
): Logger {
|
): Logger {
|
||||||
return getLogger().child(bindings ?? {}, opts);
|
return getLogger().child(bindings ?? {}, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test helpers
|
// Test helpers
|
||||||
export function setLoggerOverride(settings: LoggerSettings | null) {
|
export function setLoggerOverride(settings: LoggerSettings | null) {
|
||||||
overrideSettings = settings;
|
overrideSettings = settings;
|
||||||
cachedLogger = null;
|
cachedLogger = null;
|
||||||
cachedSettings = null;
|
cachedSettings = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetLogger() {
|
export function resetLogger() {
|
||||||
cachedLogger = null;
|
cachedLogger = null;
|
||||||
cachedSettings = null;
|
cachedSettings = null;
|
||||||
overrideSettings = null;
|
overrideSettings = null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const MAX_IMAGE_BYTES = 6 * 1024 * 1024; // 6MB
|
||||||
|
export const MAX_AUDIO_BYTES = 16 * 1024 * 1024; // 16MB
|
||||||
|
export const MAX_VIDEO_BYTES = 16 * 1024 * 1024; // 16MB
|
||||||
|
export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB
|
||||||
|
|
||||||
|
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||||
|
|
||||||
|
export function mediaKindFromMime(mime?: string | null): MediaKind {
|
||||||
|
if (!mime) return "unknown";
|
||||||
|
if (mime.startsWith("image/")) return "image";
|
||||||
|
if (mime.startsWith("audio/")) return "audio";
|
||||||
|
if (mime.startsWith("video/")) return "video";
|
||||||
|
if (mime === "application/pdf") return "document";
|
||||||
|
if (mime.startsWith("application/")) return "document";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxBytesForKind(kind: MediaKind): number {
|
||||||
|
switch (kind) {
|
||||||
|
case "image":
|
||||||
|
return MAX_IMAGE_BYTES;
|
||||||
|
case "audio":
|
||||||
|
return MAX_AUDIO_BYTES;
|
||||||
|
case "video":
|
||||||
|
return MAX_VIDEO_BYTES;
|
||||||
|
case "document":
|
||||||
|
return MAX_DOCUMENT_BYTES;
|
||||||
|
default:
|
||||||
|
return MAX_DOCUMENT_BYTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-64
@@ -12,11 +12,11 @@ const logInfo = vi.fn();
|
|||||||
vi.mock("./store.js", () => ({ saveMediaSource }));
|
vi.mock("./store.js", () => ({ saveMediaSource }));
|
||||||
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
||||||
vi.mock("../infra/ports.js", async () => {
|
vi.mock("../infra/ports.js", async () => {
|
||||||
const actual =
|
const actual =
|
||||||
await vi.importActual<typeof import("../infra/ports.js")>(
|
await vi.importActual<typeof import("../infra/ports.js")>(
|
||||||
"../infra/ports.js",
|
"../infra/ports.js",
|
||||||
);
|
);
|
||||||
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
return { ensurePortAvailable, PortInUseError: actual.PortInUseError };
|
||||||
});
|
});
|
||||||
vi.mock("./server.js", () => ({ startMediaServer }));
|
vi.mock("./server.js", () => ({ startMediaServer }));
|
||||||
vi.mock("../logger.js", () => ({ logInfo }));
|
vi.mock("../logger.js", () => ({ logInfo }));
|
||||||
@@ -25,69 +25,69 @@ const { ensureMediaHosted } = await import("./host.js");
|
|||||||
const { PortInUseError } = await import("../infra/ports.js");
|
const { PortInUseError } = await import("../infra/ports.js");
|
||||||
|
|
||||||
describe("ensureMediaHosted", () => {
|
describe("ensureMediaHosted", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws and cleans up when server not allowed to start", async () => {
|
it("throws and cleans up when server not allowed to start", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id1",
|
id: "id1",
|
||||||
path: "/tmp/file1",
|
path: "/tmp/file1",
|
||||||
size: 5,
|
size: 5,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tailnet-host");
|
getTailnetHostname.mockResolvedValue("tailnet-host");
|
||||||
ensurePortAvailable.mockResolvedValue(undefined);
|
ensurePortAvailable.mockResolvedValue(undefined);
|
||||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
ensureMediaHosted("/tmp/file1", { startServer: false }),
|
||||||
).rejects.toThrow("requires the webhook/Funnel server");
|
).rejects.toThrow("requires the webhook/Funnel server");
|
||||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
expect(rmSpy).toHaveBeenCalledWith("/tmp/file1");
|
||||||
rmSpy.mockRestore();
|
rmSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts media server when allowed", async () => {
|
it("starts media server when allowed", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id2",
|
id: "id2",
|
||||||
path: "/tmp/file2",
|
path: "/tmp/file2",
|
||||||
size: 9,
|
size: 9,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tail.net");
|
getTailnetHostname.mockResolvedValue("tail.net");
|
||||||
ensurePortAvailable.mockResolvedValue(undefined);
|
ensurePortAvailable.mockResolvedValue(undefined);
|
||||||
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
const fakeServer = { unref: vi.fn() } as unknown as Server;
|
||||||
startMediaServer.mockResolvedValue(fakeServer);
|
startMediaServer.mockResolvedValue(fakeServer);
|
||||||
|
|
||||||
const result = await ensureMediaHosted("/tmp/file2", {
|
const result = await ensureMediaHosted("/tmp/file2", {
|
||||||
startServer: true,
|
startServer: true,
|
||||||
port: 1234,
|
port: 1234,
|
||||||
});
|
});
|
||||||
expect(startMediaServer).toHaveBeenCalledWith(
|
expect(startMediaServer).toHaveBeenCalledWith(
|
||||||
1234,
|
1234,
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
expect(logInfo).toHaveBeenCalled();
|
expect(logInfo).toHaveBeenCalled();
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
url: "https://tail.net/media/id2",
|
url: "https://tail.net/media/id2",
|
||||||
id: "id2",
|
id: "id2",
|
||||||
size: 9,
|
size: 9,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips server start when port already in use", async () => {
|
it("skips server start when port already in use", async () => {
|
||||||
saveMediaSource.mockResolvedValue({
|
saveMediaSource.mockResolvedValue({
|
||||||
id: "id3",
|
id: "id3",
|
||||||
path: "/tmp/file3",
|
path: "/tmp/file3",
|
||||||
size: 7,
|
size: 7,
|
||||||
});
|
});
|
||||||
getTailnetHostname.mockResolvedValue("tail.net");
|
getTailnetHostname.mockResolvedValue("tail.net");
|
||||||
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc"));
|
||||||
|
|
||||||
const result = await ensureMediaHosted("/tmp/file3", {
|
const result = await ensureMediaHosted("/tmp/file3", {
|
||||||
startServer: false,
|
startServer: false,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
});
|
});
|
||||||
expect(startMediaServer).not.toHaveBeenCalled();
|
expect(startMediaServer).not.toHaveBeenCalled();
|
||||||
expect(result.url).toBe("https://tail.net/media/id3");
|
expect(result.url).toBe("https://tail.net/media/id3");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+40
-40
@@ -12,54 +12,54 @@ const TTL_MS = 2 * 60 * 1000;
|
|||||||
let mediaServer: import("http").Server | null = null;
|
let mediaServer: import("http").Server | null = null;
|
||||||
|
|
||||||
export type HostedMedia = {
|
export type HostedMedia = {
|
||||||
url: string;
|
url: string;
|
||||||
id: string;
|
id: string;
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function ensureMediaHosted(
|
export async function ensureMediaHosted(
|
||||||
source: string,
|
source: string,
|
||||||
opts: {
|
opts: {
|
||||||
port?: number;
|
port?: number;
|
||||||
startServer?: boolean;
|
startServer?: boolean;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<HostedMedia> {
|
): Promise<HostedMedia> {
|
||||||
const port = opts.port ?? DEFAULT_PORT;
|
const port = opts.port ?? DEFAULT_PORT;
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
|
|
||||||
const saved = await saveMediaSource(source);
|
const saved = await saveMediaSource(source);
|
||||||
const hostname = await getTailnetHostname();
|
const hostname = await getTailnetHostname();
|
||||||
|
|
||||||
// Decide whether we must start a media server.
|
// Decide whether we must start a media server.
|
||||||
const needsServerStart = await isPortFree(port);
|
const needsServerStart = await isPortFree(port);
|
||||||
if (needsServerStart && !opts.startServer) {
|
if (needsServerStart && !opts.startServer) {
|
||||||
await fs.rm(saved.path).catch(() => {});
|
await fs.rm(saved.path).catch(() => {});
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
"Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (needsServerStart && opts.startServer) {
|
if (needsServerStart && opts.startServer) {
|
||||||
if (!mediaServer) {
|
if (!mediaServer) {
|
||||||
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
mediaServer = await startMediaServer(port, TTL_MS, runtime);
|
||||||
logInfo(
|
logInfo(
|
||||||
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
`📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
mediaServer.unref?.();
|
mediaServer.unref?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://${hostname}/media/${saved.id}`;
|
const url = `https://${hostname}/media/${saved.id}`;
|
||||||
return { url, id: saved.id, size: saved.size };
|
return { url, id: saved.id, size: saved.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isPortFree(port: number) {
|
async function isPortFree(port: number) {
|
||||||
try {
|
try {
|
||||||
await ensurePortAvailable(port);
|
await ensurePortAvailable(port);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof PortInUseError) return false;
|
if (err instanceof PortInUseError) return false;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+82
-34
@@ -1,53 +1,101 @@
|
|||||||
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
||||||
|
|
||||||
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
||||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\s`]+)`?/i;
|
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
|
||||||
|
|
||||||
export function normalizeMediaSource(src: string) {
|
export function normalizeMediaSource(src: string) {
|
||||||
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanCandidate(raw: string) {
|
function cleanCandidate(raw: string) {
|
||||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidMedia(candidate: string) {
|
function isValidMedia(candidate: string) {
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
if (candidate.length > 1024) return false;
|
if (candidate.length > 1024) return false;
|
||||||
if (/\s/.test(candidate)) return false;
|
if (/\s/.test(candidate)) return false;
|
||||||
return (
|
return (
|
||||||
/^https?:\/\//i.test(candidate) ||
|
/^https?:\/\//i.test(candidate) ||
|
||||||
candidate.startsWith("/") ||
|
candidate.startsWith("/") ||
|
||||||
candidate.startsWith("./")
|
candidate.startsWith("./")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitMediaFromOutput(raw: string): {
|
export function splitMediaFromOutput(raw: string): {
|
||||||
text: string;
|
text: string;
|
||||||
mediaUrl?: string;
|
mediaUrls?: string[];
|
||||||
|
mediaUrl?: string; // legacy first item for backward compatibility
|
||||||
} {
|
} {
|
||||||
const trimmedRaw = raw.trim();
|
const trimmedRaw = raw.trim();
|
||||||
const match = MEDIA_TOKEN_RE.exec(trimmedRaw);
|
if (!trimmedRaw) return { text: "" };
|
||||||
if (!match?.[1]) return { text: trimmedRaw };
|
|
||||||
|
|
||||||
const candidate = normalizeMediaSource(cleanCandidate(match[1]));
|
const media: string[] = [];
|
||||||
const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
|
let foundMediaToken = false;
|
||||||
|
|
||||||
const cleanedText = mediaUrl
|
// Collect tokens line by line so we can strip them cleanly.
|
||||||
? trimmedRaw
|
const lines = trimmedRaw.split("\n");
|
||||||
.replace(match[0], "")
|
const keptLines: string[] = [];
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
|
||||||
.replace(/[ \t]{2,}/g, " ")
|
|
||||||
.replace(/\n{2,}/g, "\n")
|
|
||||||
.trim()
|
|
||||||
: trimmedRaw
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => !MEDIA_TOKEN_RE.test(line))
|
|
||||||
.join("\n")
|
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
|
||||||
.replace(/[ \t]{2,}/g, " ")
|
|
||||||
.replace(/\n{2,}/g, "\n")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText };
|
for (const line of lines) {
|
||||||
|
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
|
||||||
|
if (matches.length === 0) {
|
||||||
|
keptLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foundMediaToken = true;
|
||||||
|
const pieces: string[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
let hasValidMedia = false;
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const start = match.index ?? 0;
|
||||||
|
pieces.push(line.slice(cursor, start));
|
||||||
|
|
||||||
|
const payload = match[1];
|
||||||
|
const parts = payload.split(/\s+/).filter(Boolean);
|
||||||
|
const invalidParts: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const candidate = normalizeMediaSource(cleanCandidate(part));
|
||||||
|
if (isValidMedia(candidate)) {
|
||||||
|
media.push(candidate);
|
||||||
|
hasValidMedia = true;
|
||||||
|
} else {
|
||||||
|
invalidParts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasValidMedia && invalidParts.length > 0) {
|
||||||
|
pieces.push(invalidParts.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = start + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
pieces.push(line.slice(cursor));
|
||||||
|
|
||||||
|
const cleanedLine = pieces
|
||||||
|
.join("")
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// If the line becomes empty, drop it.
|
||||||
|
if (cleanedLine) {
|
||||||
|
keptLines.push(cleanedLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedText = keptLines
|
||||||
|
.join("\n")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/\n{2,}/g, "\n")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (media.length === 0) {
|
||||||
|
return { text: foundMediaToken ? cleanedText : trimmedRaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: cleanedText, mediaUrls: media, mediaUrl: media[0] };
|
||||||
}
|
}
|
||||||
+33
-33
@@ -8,45 +8,45 @@ const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
|
|||||||
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mock("./store.js", () => ({
|
vi.mock("./store.js", () => ({
|
||||||
getMediaDir: () => MEDIA_DIR,
|
getMediaDir: () => MEDIA_DIR,
|
||||||
cleanOldMedia,
|
cleanOldMedia,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { startMediaServer } = await import("./server.js");
|
const { startMediaServer } = await import("./server.js");
|
||||||
|
|
||||||
describe("media server", () => {
|
describe("media server", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves media and cleans up after send", async () => {
|
it("serves media and cleans up after send", async () => {
|
||||||
const file = path.join(MEDIA_DIR, "file1");
|
const file = path.join(MEDIA_DIR, "file1");
|
||||||
await fs.writeFile(file, "hello");
|
await fs.writeFile(file, "hello");
|
||||||
const server = await startMediaServer(0, 5_000);
|
const server = await startMediaServer(0, 5_000);
|
||||||
const port = (server.address() as AddressInfo).port;
|
const port = (server.address() as AddressInfo).port;
|
||||||
const res = await fetch(`http://localhost:${port}/media/file1`);
|
const res = await fetch(`http://localhost:${port}/media/file1`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.text()).toBe("hello");
|
expect(await res.text()).toBe("hello");
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
await expect(fs.stat(file)).rejects.toThrow();
|
await expect(fs.stat(file)).rejects.toThrow();
|
||||||
await new Promise((r) => server.close(r));
|
await new Promise((r) => server.close(r));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expires old media", async () => {
|
it("expires old media", async () => {
|
||||||
const file = path.join(MEDIA_DIR, "old");
|
const file = path.join(MEDIA_DIR, "old");
|
||||||
await fs.writeFile(file, "stale");
|
await fs.writeFile(file, "stale");
|
||||||
const past = Date.now() - 10_000;
|
const past = Date.now() - 10_000;
|
||||||
await fs.utimes(file, past / 1000, past / 1000);
|
await fs.utimes(file, past / 1000, past / 1000);
|
||||||
const server = await startMediaServer(0, 1_000);
|
const server = await startMediaServer(0, 1_000);
|
||||||
const port = (server.address() as AddressInfo).port;
|
const port = (server.address() as AddressInfo).port;
|
||||||
const res = await fetch(`http://localhost:${port}/media/old`);
|
const res = await fetch(`http://localhost:${port}/media/old`);
|
||||||
expect(res.status).toBe(410);
|
expect(res.status).toBe(410);
|
||||||
await expect(fs.stat(file)).rejects.toThrow();
|
await expect(fs.stat(file)).rejects.toThrow();
|
||||||
await new Promise((r) => server.close(r));
|
await new Promise((r) => server.close(r));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+42
-42
@@ -9,53 +9,53 @@ import { cleanOldMedia, getMediaDir } from "./store.js";
|
|||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
export function attachMediaRoutes(
|
export function attachMediaRoutes(
|
||||||
app: Express,
|
app: Express,
|
||||||
ttlMs = DEFAULT_TTL_MS,
|
ttlMs = DEFAULT_TTL_MS,
|
||||||
_runtime: RuntimeEnv = defaultRuntime,
|
_runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const mediaDir = getMediaDir();
|
const mediaDir = getMediaDir();
|
||||||
|
|
||||||
app.get("/media/:id", async (req, res) => {
|
app.get("/media/:id", async (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const file = path.join(mediaDir, id);
|
const file = path.join(mediaDir, id);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(file);
|
const stat = await fs.stat(file);
|
||||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||||
await fs.rm(file).catch(() => {});
|
await fs.rm(file).catch(() => {});
|
||||||
res.status(410).send("expired");
|
res.status(410).send("expired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.sendFile(file);
|
res.sendFile(file);
|
||||||
// best-effort single-use cleanup after response ends
|
// best-effort single-use cleanup after response ends
|
||||||
res.on("finish", () => {
|
res.on("finish", () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fs.rm(file).catch(() => {});
|
fs.rm(file).catch(() => {});
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
res.status(404).send("not found");
|
res.status(404).send("not found");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// periodic cleanup
|
// periodic cleanup
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
void cleanOldMedia(ttlMs);
|
void cleanOldMedia(ttlMs);
|
||||||
}, ttlMs).unref();
|
}, ttlMs).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMediaServer(
|
export async function startMediaServer(
|
||||||
port: number,
|
port: number,
|
||||||
ttlMs = DEFAULT_TTL_MS,
|
ttlMs = DEFAULT_TTL_MS,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<Server> {
|
): Promise<Server> {
|
||||||
const app = express();
|
const app = express();
|
||||||
attachMediaRoutes(app, ttlMs, runtime);
|
attachMediaRoutes(app, ttlMs, runtime);
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = app.listen(port);
|
const server = app.listen(port);
|
||||||
server.once("listening", () => resolve(server));
|
server.once("listening", () => resolve(server));
|
||||||
server.once("error", (err) => {
|
server.once("error", (err) => {
|
||||||
runtime.error(danger(`Media server failed: ${String(err)}`));
|
runtime.error(danger(`Media server failed: ${String(err)}`));
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+39
-39
@@ -7,54 +7,54 @@ const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
|||||||
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
const HOME = path.join(realOs.tmpdir(), "warelay-home-test");
|
||||||
|
|
||||||
vi.mock("node:os", () => ({
|
vi.mock("node:os", () => ({
|
||||||
default: { homedir: () => HOME },
|
default: { homedir: () => HOME },
|
||||||
homedir: () => HOME,
|
homedir: () => HOME,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const store = await import("./store.js");
|
const store = await import("./store.js");
|
||||||
|
|
||||||
describe("media store", () => {
|
describe("media store", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates and returns media directory", async () => {
|
it("creates and returns media directory", async () => {
|
||||||
const dir = await store.ensureMediaDir();
|
const dir = await store.ensureMediaDir();
|
||||||
expect(dir).toContain("warelay-home-test");
|
expect(dir).toContain("warelay-home-test");
|
||||||
const stat = await fs.stat(dir);
|
const stat = await fs.stat(dir);
|
||||||
expect(stat.isDirectory()).toBe(true);
|
expect(stat.isDirectory()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves buffers and enforces size limit", async () => {
|
it("saves buffers and enforces size limit", async () => {
|
||||||
const buf = Buffer.from("hello");
|
const buf = Buffer.from("hello");
|
||||||
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
const saved = await store.saveMediaBuffer(buf, "text/plain");
|
||||||
const savedStat = await fs.stat(saved.path);
|
const savedStat = await fs.stat(saved.path);
|
||||||
expect(savedStat.size).toBe(buf.length);
|
expect(savedStat.size).toBe(buf.length);
|
||||||
expect(saved.contentType).toBe("text/plain");
|
expect(saved.contentType).toBe("text/plain");
|
||||||
|
|
||||||
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
|
||||||
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
|
||||||
"Media exceeds 5MB limit",
|
"Media exceeds 5MB limit",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("copies local files and cleans old media", async () => {
|
it("copies local files and cleans old media", async () => {
|
||||||
const srcFile = path.join(HOME, "tmp-src.txt");
|
const srcFile = path.join(HOME, "tmp-src.txt");
|
||||||
await fs.mkdir(HOME, { recursive: true });
|
await fs.mkdir(HOME, { recursive: true });
|
||||||
await fs.writeFile(srcFile, "local file");
|
await fs.writeFile(srcFile, "local file");
|
||||||
const saved = await store.saveMediaSource(srcFile);
|
const saved = await store.saveMediaSource(srcFile);
|
||||||
expect(saved.size).toBe(10);
|
expect(saved.size).toBe(10);
|
||||||
const savedStat = await fs.stat(saved.path);
|
const savedStat = await fs.stat(saved.path);
|
||||||
expect(savedStat.isFile()).toBe(true);
|
expect(savedStat.isFile()).toBe(true);
|
||||||
|
|
||||||
// make the file look old and ensure cleanOldMedia removes it
|
// make the file look old and ensure cleanOldMedia removes it
|
||||||
const past = Date.now() - 10_000;
|
const past = Date.now() - 10_000;
|
||||||
await fs.utimes(saved.path, past / 1000, past / 1000);
|
await fs.utimes(saved.path, past / 1000, past / 1000);
|
||||||
await store.cleanOldMedia(1);
|
await store.cleanOldMedia(1);
|
||||||
await expect(fs.stat(saved.path)).rejects.toThrow();
|
await expect(fs.stat(saved.path)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+80
-80
@@ -11,108 +11,108 @@ const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
|||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
export function getMediaDir() {
|
export function getMediaDir() {
|
||||||
return MEDIA_DIR;
|
return MEDIA_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureMediaDir() {
|
export async function ensureMediaDir() {
|
||||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||||
return MEDIA_DIR;
|
return MEDIA_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
||||||
await ensureMediaDir();
|
await ensureMediaDir();
|
||||||
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(async (file) => {
|
entries.map(async (file) => {
|
||||||
const full = path.join(MEDIA_DIR, file);
|
const full = path.join(MEDIA_DIR, file);
|
||||||
const stat = await fs.stat(full).catch(() => null);
|
const stat = await fs.stat(full).catch(() => null);
|
||||||
if (!stat) return;
|
if (!stat) return;
|
||||||
if (now - stat.mtimeMs > ttlMs) {
|
if (now - stat.mtimeMs > ttlMs) {
|
||||||
await fs.rm(full).catch(() => {});
|
await fs.rm(full).catch(() => {});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeUrl(src: string) {
|
function looksLikeUrl(src: string) {
|
||||||
return /^https?:\/\//i.test(src);
|
return /^https?:\/\//i.test(src);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadToFile(
|
async function downloadToFile(
|
||||||
url: string,
|
url: string,
|
||||||
dest: string,
|
dest: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const req = request(url, { headers }, (res) => {
|
const req = request(url, { headers }, (res) => {
|
||||||
if (!res.statusCode || res.statusCode >= 400) {
|
if (!res.statusCode || res.statusCode >= 400) {
|
||||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const out = createWriteStream(dest);
|
const out = createWriteStream(dest);
|
||||||
res.on("data", (chunk) => {
|
res.on("data", (chunk) => {
|
||||||
total += chunk.length;
|
total += chunk.length;
|
||||||
if (total > MAX_BYTES) {
|
if (total > MAX_BYTES) {
|
||||||
req.destroy(new Error("Media exceeds 5MB limit"));
|
req.destroy(new Error("Media exceeds 5MB limit"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
pipeline(res, out)
|
pipeline(res, out)
|
||||||
.then(() => resolve())
|
.then(() => resolve())
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SavedMedia = {
|
export type SavedMedia = {
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
size: number;
|
size: number;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function saveMediaSource(
|
export async function saveMediaSource(
|
||||||
source: string,
|
source: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
subdir = "",
|
subdir = "",
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await cleanOldMedia();
|
await cleanOldMedia();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const dest = path.join(dir, id);
|
const dest = path.join(dir, id);
|
||||||
if (looksLikeUrl(source)) {
|
if (looksLikeUrl(source)) {
|
||||||
await downloadToFile(source, dest, headers);
|
await downloadToFile(source, dest, headers);
|
||||||
const stat = await fs.stat(dest);
|
const stat = await fs.stat(dest);
|
||||||
return { id, path: dest, size: stat.size };
|
return { id, path: dest, size: stat.size };
|
||||||
}
|
}
|
||||||
// local path
|
// local path
|
||||||
const stat = await fs.stat(source);
|
const stat = await fs.stat(source);
|
||||||
if (!stat.isFile()) {
|
if (!stat.isFile()) {
|
||||||
throw new Error("Media path is not a file");
|
throw new Error("Media path is not a file");
|
||||||
}
|
}
|
||||||
if (stat.size > MAX_BYTES) {
|
if (stat.size > MAX_BYTES) {
|
||||||
throw new Error("Media exceeds 5MB limit");
|
throw new Error("Media exceeds 5MB limit");
|
||||||
}
|
}
|
||||||
await fs.copyFile(source, dest);
|
await fs.copyFile(source, dest);
|
||||||
return { id, path: dest, size: stat.size };
|
return { id, path: dest, size: stat.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMediaBuffer(
|
export async function saveMediaBuffer(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType?: string,
|
contentType?: string,
|
||||||
subdir = "inbound",
|
subdir = "inbound",
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
if (buffer.byteLength > MAX_BYTES) {
|
if (buffer.byteLength > MAX_BYTES) {
|
||||||
throw new Error("Media exceeds 5MB limit");
|
throw new Error("Media exceeds 5MB limit");
|
||||||
}
|
}
|
||||||
const dir = path.join(MEDIA_DIR, subdir);
|
const dir = path.join(MEDIA_DIR, subdir);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const dest = path.join(dir, id);
|
const dest = path.join(dir, id);
|
||||||
await fs.writeFile(dest, buffer);
|
await fs.writeFile(dest, buffer);
|
||||||
return { id, path: dest, size: buffer.byteLength, contentType };
|
return { id, path: dest, size: buffer.byteLength, contentType };
|
||||||
}
|
}
|
||||||
@@ -3,53 +3,53 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { enqueueCommand, getQueueSize } from "./command-queue.js";
|
import { enqueueCommand, getQueueSize } from "./command-queue.js";
|
||||||
|
|
||||||
describe("command queue", () => {
|
describe("command queue", () => {
|
||||||
it("runs tasks one at a time in order", async () => {
|
it("runs tasks one at a time in order", async () => {
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let maxActive = 0;
|
let maxActive = 0;
|
||||||
const calls: number[] = [];
|
const calls: number[] = [];
|
||||||
|
|
||||||
const makeTask = (id: number) => async () => {
|
const makeTask = (id: number) => async () => {
|
||||||
active += 1;
|
active += 1;
|
||||||
maxActive = Math.max(maxActive, active);
|
maxActive = Math.max(maxActive, active);
|
||||||
calls.push(id);
|
calls.push(id);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
active -= 1;
|
active -= 1;
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
enqueueCommand(makeTask(1)),
|
enqueueCommand(makeTask(1)),
|
||||||
enqueueCommand(makeTask(2)),
|
enqueueCommand(makeTask(2)),
|
||||||
enqueueCommand(makeTask(3)),
|
enqueueCommand(makeTask(3)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(results).toEqual([1, 2, 3]);
|
expect(results).toEqual([1, 2, 3]);
|
||||||
expect(calls).toEqual([1, 2, 3]);
|
expect(calls).toEqual([1, 2, 3]);
|
||||||
expect(maxActive).toBe(1);
|
expect(maxActive).toBe(1);
|
||||||
expect(getQueueSize()).toBe(0);
|
expect(getQueueSize()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onWait callback when a task waits past the threshold", async () => {
|
it("invokes onWait callback when a task waits past the threshold", async () => {
|
||||||
let waited: number | null = null;
|
let waited: number | null = null;
|
||||||
let queuedAhead: number | null = null;
|
let queuedAhead: number | null = null;
|
||||||
|
|
||||||
// First task holds the queue long enough to trigger wait notice.
|
// First task holds the queue long enough to trigger wait notice.
|
||||||
const first = enqueueCommand(async () => {
|
const first = enqueueCommand(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
});
|
});
|
||||||
|
|
||||||
const second = enqueueCommand(async () => {}, {
|
const second = enqueueCommand(async () => {}, {
|
||||||
warnAfterMs: 5,
|
warnAfterMs: 5,
|
||||||
onWait: (ms, ahead) => {
|
onWait: (ms, ahead) => {
|
||||||
waited = ms;
|
waited = ms;
|
||||||
queuedAhead = ahead;
|
queuedAhead = ahead;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
expect(waited).not.toBeNull();
|
expect(waited).not.toBeNull();
|
||||||
expect(waited as number).toBeGreaterThanOrEqual(5);
|
expect(waited as number).toBeGreaterThanOrEqual(5);
|
||||||
expect(queuedAhead).toBe(0);
|
expect(queuedAhead).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,57 +2,57 @@
|
|||||||
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
// Ensures only one command runs at a time across webhook, poller, and web inbox flows.
|
||||||
|
|
||||||
type QueueEntry = {
|
type QueueEntry = {
|
||||||
task: () => Promise<unknown>;
|
task: () => Promise<unknown>;
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
reject: (reason?: unknown) => void;
|
reject: (reason?: unknown) => void;
|
||||||
enqueuedAt: number;
|
enqueuedAt: number;
|
||||||
warnAfterMs: number;
|
warnAfterMs: number;
|
||||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
let draining = false;
|
let draining = false;
|
||||||
|
|
||||||
async function drainQueue() {
|
async function drainQueue() {
|
||||||
if (draining) return;
|
if (draining) return;
|
||||||
draining = true;
|
draining = true;
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const entry = queue.shift() as QueueEntry;
|
const entry = queue.shift() as QueueEntry;
|
||||||
const waitedMs = Date.now() - entry.enqueuedAt;
|
const waitedMs = Date.now() - entry.enqueuedAt;
|
||||||
if (waitedMs >= entry.warnAfterMs) {
|
if (waitedMs >= entry.warnAfterMs) {
|
||||||
entry.onWait?.(waitedMs, queue.length);
|
entry.onWait?.(waitedMs, queue.length);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await entry.task();
|
const result = await entry.task();
|
||||||
entry.resolve(result);
|
entry.resolve(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entry.reject(err);
|
entry.reject(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
draining = false;
|
draining = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueCommand<T>(
|
export function enqueueCommand<T>(
|
||||||
task: () => Promise<T>,
|
task: () => Promise<T>,
|
||||||
opts?: {
|
opts?: {
|
||||||
warnAfterMs?: number;
|
warnAfterMs?: number;
|
||||||
onWait?: (waitMs: number, queuedAhead: number) => void;
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
||||||
},
|
},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
queue.push({
|
queue.push({
|
||||||
task: () => task(),
|
task: () => task(),
|
||||||
resolve: (value) => resolve(value as T),
|
resolve: (value) => resolve(value as T),
|
||||||
reject,
|
reject,
|
||||||
enqueuedAt: Date.now(),
|
enqueuedAt: Date.now(),
|
||||||
warnAfterMs,
|
warnAfterMs,
|
||||||
onWait: opts?.onWait,
|
onWait: opts?.onWait,
|
||||||
});
|
});
|
||||||
void drainQueue();
|
void drainQueue();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getQueueSize() {
|
export function getQueueSize() {
|
||||||
return queue.length + (draining ? 1 : 0);
|
return queue.length + (draining ? 1 : 0);
|
||||||
}
|
}
|
||||||
+69
-69
@@ -8,86 +8,86 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
// Simple promise-wrapped execFile with optional verbosity logging.
|
// Simple promise-wrapped execFile with optional verbosity logging.
|
||||||
export async function runExec(
|
export async function runExec(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
const options =
|
const options =
|
||||||
typeof opts === "number"
|
typeof opts === "number"
|
||||||
? { timeout: opts, encoding: "utf8" as const }
|
? { timeout: opts, encoding: "utf8" as const }
|
||||||
: {
|
: {
|
||||||
timeout: opts.timeoutMs,
|
timeout: opts.timeoutMs,
|
||||||
maxBuffer: opts.maxBuffer,
|
maxBuffer: opts.maxBuffer,
|
||||||
encoding: "utf8" as const,
|
encoding: "utf8" as const,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execFileAsync(command, args, options);
|
const { stdout, stderr } = await execFileAsync(command, args, options);
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
if (stdout.trim()) logDebug(stdout.trim());
|
if (stdout.trim()) logDebug(stdout.trim());
|
||||||
if (stderr.trim()) logError(stderr.trim());
|
if (stderr.trim()) logError(stderr.trim());
|
||||||
}
|
}
|
||||||
return { stdout, stderr };
|
return { stdout, stderr };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
logError(danger(`Command failed: ${command} ${args.join(" ")}`));
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpawnResult = {
|
export type SpawnResult = {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
code: number | null;
|
code: number | null;
|
||||||
signal: NodeJS.Signals | null;
|
signal: NodeJS.Signals | null;
|
||||||
killed: boolean;
|
killed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandOptions = {
|
export type CommandOptions = {
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCommandWithTimeout(
|
export async function runCommandWithTimeout(
|
||||||
argv: string[],
|
argv: string[],
|
||||||
optionsOrTimeout: number | CommandOptions,
|
optionsOrTimeout: number | CommandOptions,
|
||||||
): Promise<SpawnResult> {
|
): Promise<SpawnResult> {
|
||||||
const options: CommandOptions =
|
const options: CommandOptions =
|
||||||
typeof optionsOrTimeout === "number"
|
typeof optionsOrTimeout === "number"
|
||||||
? { timeoutMs: optionsOrTimeout }
|
? { timeoutMs: optionsOrTimeout }
|
||||||
: optionsOrTimeout;
|
: optionsOrTimeout;
|
||||||
const { timeoutMs, cwd } = options;
|
const { timeoutMs, cwd } = options;
|
||||||
|
|
||||||
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
// Spawn with inherited stdin (TTY) so tools like `claude` don't hang.
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const child = spawn(argv[0], argv.slice(1), {
|
const child = spawn(argv[0], argv.slice(1), {
|
||||||
stdio: ["inherit", "pipe", "pipe"],
|
stdio: ["inherit", "pipe", "pipe"],
|
||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
child.stdout?.on("data", (d) => {
|
child.stdout?.on("data", (d) => {
|
||||||
stdout += d.toString();
|
stdout += d.toString();
|
||||||
});
|
});
|
||||||
child.stderr?.on("data", (d) => {
|
child.stderr?.on("data", (d) => {
|
||||||
stderr += d.toString();
|
stderr += d.toString();
|
||||||
});
|
});
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
child.on("close", (code, signal) => {
|
child.on("close", (code, signal) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import * as mod from "./provider-web.js";
|
||||||
|
|
||||||
|
describe("provider-web barrel", () => {
|
||||||
|
it("exports the expected web helpers", () => {
|
||||||
|
expect(mod.createWaSocket).toBeTypeOf("function");
|
||||||
|
expect(mod.loginWeb).toBeTypeOf("function");
|
||||||
|
expect(mod.monitorWebProvider).toBeTypeOf("function");
|
||||||
|
expect(mod.sendMessageWeb).toBeTypeOf("function");
|
||||||
|
expect(mod.monitorWebInbox).toBeTypeOf("function");
|
||||||
|
expect(mod.pickProvider).toBeTypeOf("function");
|
||||||
|
expect(mod.WA_WEB_AUTH_DIR).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import fsSync from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { MockBaileysSocket } from "../test/mocks/baileys.js";
|
|
||||||
import { createMockBaileys } from "../test/mocks/baileys.js";
|
|
||||||
|
|
||||||
vi.mock("@whiskeysockets/baileys", () => {
|
|
||||||
const created = createMockBaileys();
|
|
||||||
(globalThis as Record<PropertyKey, unknown>)[
|
|
||||||
Symbol.for("warelay:lastSocket")
|
|
||||||
] = created.lastSocket;
|
|
||||||
return created.mod;
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./media/store.js", () => ({
|
|
||||||
saveMediaBuffer: vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(async (_buf: Buffer, contentType?: string) => ({
|
|
||||||
id: "mid",
|
|
||||||
path: "/tmp/mid",
|
|
||||||
size: _buf.length,
|
|
||||||
contentType,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function getLastSocket(): MockBaileysSocket {
|
|
||||||
const getter = (globalThis as Record<PropertyKey, unknown>)[
|
|
||||||
Symbol.for("warelay:lastSocket")
|
|
||||||
];
|
|
||||||
if (typeof getter === "function")
|
|
||||||
return (getter as () => MockBaileysSocket)();
|
|
||||||
if (!getter) throw new Error("Baileys mock not initialized");
|
|
||||||
throw new Error("Invalid Baileys socket getter");
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("qrcode-terminal", () => ({
|
|
||||||
default: { generate: vi.fn() },
|
|
||||||
generate: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { monitorWebProvider } from "./index.js";
|
|
||||||
import { resetLogger, setLoggerOverride } from "./logging.js";
|
|
||||||
import {
|
|
||||||
createWaSocket,
|
|
||||||
loginWeb,
|
|
||||||
logWebSelfId,
|
|
||||||
monitorWebInbox,
|
|
||||||
sendMessageWeb,
|
|
||||||
waitForWaConnection,
|
|
||||||
} from "./provider-web.js";
|
|
||||||
|
|
||||||
const baileys = (await import(
|
|
||||||
"@whiskeysockets/baileys"
|
|
||||||
)) as unknown as typeof import("@whiskeysockets/baileys") & {
|
|
||||||
makeWASocket: ReturnType<typeof vi.fn>;
|
|
||||||
useMultiFileAuthState: ReturnType<typeof vi.fn>;
|
|
||||||
fetchLatestBaileysVersion: ReturnType<typeof vi.fn>;
|
|
||||||
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("provider-web", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
const recreated = createMockBaileys();
|
|
||||||
(globalThis as Record<PropertyKey, unknown>)[
|
|
||||||
Symbol.for("warelay:lastSocket")
|
|
||||||
] = recreated.lastSocket;
|
|
||||||
baileys.makeWASocket.mockImplementation(recreated.mod.makeWASocket);
|
|
||||||
baileys.useMultiFileAuthState.mockImplementation(
|
|
||||||
recreated.mod.useMultiFileAuthState,
|
|
||||||
);
|
|
||||||
baileys.fetchLatestBaileysVersion.mockImplementation(
|
|
||||||
recreated.mod.fetchLatestBaileysVersion,
|
|
||||||
);
|
|
||||||
baileys.makeCacheableSignalKeyStore.mockImplementation(
|
|
||||||
recreated.mod.makeCacheableSignalKeyStore,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
resetLogger();
|
|
||||||
setLoggerOverride(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates WA socket with QR handler", async () => {
|
|
||||||
await createWaSocket(true, false);
|
|
||||||
const makeWASocket = baileys.makeWASocket as ReturnType<typeof vi.fn>;
|
|
||||||
expect(makeWASocket).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ printQRInTerminal: false }),
|
|
||||||
);
|
|
||||||
const passed = makeWASocket.mock.calls[0][0];
|
|
||||||
const passedLogger = (
|
|
||||||
passed as { logger?: { level?: string; trace?: unknown } }
|
|
||||||
).logger;
|
|
||||||
expect(passedLogger?.level).toBe("silent");
|
|
||||||
expect(typeof passedLogger?.trace).toBe("function");
|
|
||||||
const sock = getLastSocket();
|
|
||||||
const saveCreds = (
|
|
||||||
await baileys.useMultiFileAuthState.mock.results[0].value
|
|
||||||
).saveCreds;
|
|
||||||
// trigger creds.update listener
|
|
||||||
sock.ev.emit("creds.update", {});
|
|
||||||
expect(saveCreds).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits for connection open", async () => {
|
|
||||||
const ev = new EventEmitter();
|
|
||||||
const promise = waitForWaConnection({ ev } as unknown as ReturnType<
|
|
||||||
typeof baileys.makeWASocket
|
|
||||||
>);
|
|
||||||
ev.emit("connection.update", { connection: "open" });
|
|
||||||
await expect(promise).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when connection closes", async () => {
|
|
||||||
const ev = new EventEmitter();
|
|
||||||
const promise = waitForWaConnection({ ev } as unknown as ReturnType<
|
|
||||||
typeof baileys.makeWASocket
|
|
||||||
>);
|
|
||||||
ev.emit("connection.update", {
|
|
||||||
connection: "close",
|
|
||||||
lastDisconnect: new Error("bye"),
|
|
||||||
});
|
|
||||||
await expect(promise).rejects.toBeInstanceOf(Error);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends message via web and closes socket", async () => {
|
|
||||||
await sendMessageWeb("+1555", "hi", { verbose: false });
|
|
||||||
const sock = getLastSocket();
|
|
||||||
expect(sock.sendMessage).toHaveBeenCalled();
|
|
||||||
expect(sock.ws.close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loginWeb waits for connection and closes", async () => {
|
|
||||||
const closeSpy = vi.fn();
|
|
||||||
const ev = new EventEmitter();
|
|
||||||
baileys.makeWASocket.mockImplementation(() => ({
|
|
||||||
ev,
|
|
||||||
ws: { close: closeSpy },
|
|
||||||
sendPresenceUpdate: vi.fn(),
|
|
||||||
sendMessage: vi.fn(),
|
|
||||||
}));
|
|
||||||
const waiter: typeof waitForWaConnection = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(undefined);
|
|
||||||
await loginWeb(false, waiter);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
||||||
expect(closeSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("monitorWebInbox streams inbound messages", async () => {
|
|
||||||
const onMessage = vi.fn(async (msg) => {
|
|
||||||
await msg.sendComposing();
|
|
||||||
await msg.reply("pong");
|
|
||||||
});
|
|
||||||
|
|
||||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
|
||||||
const sock = getLastSocket();
|
|
||||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
|
||||||
const upsert = {
|
|
||||||
type: "notify",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
|
||||||
message: { conversation: "ping" },
|
|
||||||
messageTimestamp: 1_700_000_000,
|
|
||||||
pushName: "Tester",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
expect(onMessage).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ body: "ping", from: "+999", to: "+123" }),
|
|
||||||
);
|
|
||||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
remoteJid: "999@s.whatsapp.net",
|
|
||||||
id: "abc",
|
|
||||||
participant: undefined,
|
|
||||||
fromMe: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
|
||||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith(
|
|
||||||
"composing",
|
|
||||||
"999@s.whatsapp.net",
|
|
||||||
);
|
|
||||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
|
||||||
text: "pong",
|
|
||||||
});
|
|
||||||
|
|
||||||
await listener.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("monitorWebInbox captures media path for image messages", async () => {
|
|
||||||
const onMessage = vi.fn();
|
|
||||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
|
||||||
const sock = getLastSocket();
|
|
||||||
const upsert = {
|
|
||||||
type: "notify",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" },
|
|
||||||
message: { imageMessage: { mimetype: "image/jpeg" } },
|
|
||||||
messageTimestamp: 1_700_000_100,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
expect(onMessage).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
body: "<media:image>",
|
|
||||||
mediaPath: "/tmp/mid",
|
|
||||||
mediaType: "image/jpeg",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
remoteJid: "888@s.whatsapp.net",
|
|
||||||
id: "med1",
|
|
||||||
participant: undefined,
|
|
||||||
fromMe: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
|
||||||
await listener.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("monitorWebInbox logs inbound bodies to file", async () => {
|
|
||||||
const logPath = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`warelay-log-test-${crypto.randomUUID()}.log`,
|
|
||||||
);
|
|
||||||
setLoggerOverride({ level: "trace", file: logPath });
|
|
||||||
|
|
||||||
const onMessage = vi.fn();
|
|
||||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
|
||||||
const sock = getLastSocket();
|
|
||||||
const upsert = {
|
|
||||||
type: "notify",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
|
||||||
message: { conversation: "ping" },
|
|
||||||
messageTimestamp: 1_700_000_000,
|
|
||||||
pushName: "Tester",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
const content = fsSync.readFileSync(logPath, "utf-8");
|
|
||||||
expect(content).toContain('"module":"web-inbound"');
|
|
||||||
expect(content).toContain('"body":"ping"');
|
|
||||||
await listener.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("monitorWebInbox includes participant when marking group messages read", async () => {
|
|
||||||
const onMessage = vi.fn();
|
|
||||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
|
||||||
const sock = getLastSocket();
|
|
||||||
const upsert = {
|
|
||||||
type: "notify",
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
key: {
|
|
||||||
id: "grp1",
|
|
||||||
fromMe: false,
|
|
||||||
remoteJid: "12345-67890@g.us",
|
|
||||||
participant: "111@s.whatsapp.net",
|
|
||||||
},
|
|
||||||
message: { conversation: "group ping" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
sock.ev.emit("messages.upsert", upsert);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
remoteJid: "12345-67890@g.us",
|
|
||||||
id: "grp1",
|
|
||||||
participant: "111@s.whatsapp.net",
|
|
||||||
fromMe: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await listener.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("monitorWebProvider falls back to text when media send fails", async () => {
|
|
||||||
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
|
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const sendComposing = vi.fn();
|
|
||||||
const resolver = vi.fn().mockResolvedValue({
|
|
||||||
text: "hi",
|
|
||||||
mediaUrl: "https://example.com/img.png",
|
|
||||||
});
|
|
||||||
|
|
||||||
let capturedOnMessage:
|
|
||||||
| ((msg: import("./provider-web.js").WebInboundMessage) => Promise<void>)
|
|
||||||
| undefined;
|
|
||||||
const listenerFactory = async (opts: {
|
|
||||||
onMessage: (
|
|
||||||
msg: import("./provider-web.js").WebInboundMessage,
|
|
||||||
) => Promise<void>;
|
|
||||||
}) => {
|
|
||||||
capturedOnMessage = opts.onMessage;
|
|
||||||
return { close: vi.fn() };
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
body: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(1024),
|
|
||||||
headers: { get: () => "image/png" },
|
|
||||||
status: 200,
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
|
||||||
|
|
||||||
expect(capturedOnMessage).toBeDefined();
|
|
||||||
await capturedOnMessage?.({
|
|
||||||
body: "hello",
|
|
||||||
from: "+1",
|
|
||||||
to: "+2",
|
|
||||||
id: "msg1",
|
|
||||||
sendComposing,
|
|
||||||
reply,
|
|
||||||
sendMedia,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendMedia).toHaveBeenCalled();
|
|
||||||
expect(reply).toHaveBeenCalledWith("hi");
|
|
||||||
fetchMock.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs outbound replies to file", async () => {
|
|
||||||
const logPath = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`warelay-log-test-${crypto.randomUUID()}.log`,
|
|
||||||
);
|
|
||||||
setLoggerOverride({ level: "trace", file: logPath });
|
|
||||||
|
|
||||||
let capturedOnMessage:
|
|
||||||
| ((msg: import("./provider-web.js").WebInboundMessage) => Promise<void>)
|
|
||||||
| undefined;
|
|
||||||
const listenerFactory = async (opts: {
|
|
||||||
onMessage: (
|
|
||||||
msg: import("./provider-web.js").WebInboundMessage,
|
|
||||||
) => Promise<void>;
|
|
||||||
}) => {
|
|
||||||
capturedOnMessage = opts.onMessage;
|
|
||||||
return { close: vi.fn() };
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
|
|
||||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
|
||||||
expect(capturedOnMessage).toBeDefined();
|
|
||||||
|
|
||||||
await capturedOnMessage?.({
|
|
||||||
body: "hello",
|
|
||||||
from: "+1",
|
|
||||||
to: "+2",
|
|
||||||
id: "msg1",
|
|
||||||
sendComposing: vi.fn(),
|
|
||||||
reply: vi.fn(),
|
|
||||||
sendMedia: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = fsSync.readFileSync(logPath, "utf-8");
|
|
||||||
expect(content).toContain('"module":"web-auto-reply"');
|
|
||||||
expect(content).toContain('"text":"auto"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logWebSelfId prints cached E.164 when creds exist", () => {
|
|
||||||
const existsSpy = vi
|
|
||||||
.spyOn(fsSync, "existsSync")
|
|
||||||
.mockReturnValue(true as never);
|
|
||||||
const readSpy = vi
|
|
||||||
.spyOn(fsSync, "readFileSync")
|
|
||||||
.mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } }));
|
|
||||||
const runtime = {
|
|
||||||
log: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
exit: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
logWebSelfId(runtime as never, true);
|
|
||||||
|
|
||||||
expect(runtime.log).toHaveBeenCalledWith(
|
|
||||||
"Web Provider: +12345 (jid 12345@s.whatsapp.net)",
|
|
||||||
);
|
|
||||||
existsSpy.mockRestore();
|
|
||||||
readSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+28
-683
@@ -1,683 +1,28 @@
|
|||||||
import fsSync from "node:fs";
|
// Barrel exports for the web provider pieces. Splitting the original 900+ line
|
||||||
import fs from "node:fs/promises";
|
// module keeps responsibilities small and testable without changing the public API.
|
||||||
import os from "node:os";
|
export {
|
||||||
import path from "node:path";
|
DEFAULT_WEB_MEDIA_BYTES,
|
||||||
import type { proto } from "@whiskeysockets/baileys";
|
monitorWebProvider,
|
||||||
import {
|
type WebMonitorTuning,
|
||||||
type AnyMessageContent,
|
} from "./web/auto-reply.js";
|
||||||
DisconnectReason,
|
export {
|
||||||
downloadMediaMessage,
|
extractMediaPlaceholder,
|
||||||
fetchLatestBaileysVersion,
|
extractText,
|
||||||
makeCacheableSignalKeyStore,
|
monitorWebInbox,
|
||||||
makeWASocket,
|
type WebInboundMessage,
|
||||||
useMultiFileAuthState,
|
type WebListenerCloseReason,
|
||||||
type WAMessage,
|
} from "./web/inbound.js";
|
||||||
} from "@whiskeysockets/baileys";
|
export { loginWeb } from "./web/login.js";
|
||||||
import qrcode from "qrcode-terminal";
|
export { loadWebMedia, optimizeImageToJpeg } from "./web/media.js";
|
||||||
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
export { sendMessageWeb } from "./web/outbound.js";
|
||||||
import { waitForever } from "./cli/wait.js";
|
export {
|
||||||
import { danger, info, isVerbose, logVerbose, success } from "./globals.js";
|
createWaSocket,
|
||||||
import { logInfo } from "./logger.js";
|
formatError,
|
||||||
import { getChildLogger } from "./logging.js";
|
getStatusCode,
|
||||||
import { saveMediaBuffer } from "./media/store.js";
|
logoutWeb,
|
||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
logWebSelfId,
|
||||||
import type { Provider } from "./utils.js";
|
pickProvider,
|
||||||
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
|
WA_WEB_AUTH_DIR,
|
||||||
|
waitForWaConnection,
|
||||||
function formatDuration(ms: number) {
|
webAuthExists,
|
||||||
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
} from "./web/session.js";
|
||||||
}
|
|
||||||
|
|
||||||
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
|
|
||||||
|
|
||||||
export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
|
||||||
const logger = getChildLogger(
|
|
||||||
{ module: "baileys" },
|
|
||||||
{
|
|
||||||
level: verbose ? "info" : "silent",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Some Baileys internals call logger.trace even when silent; ensure it's present.
|
|
||||||
const loggerAny = logger as unknown as Record<string, unknown>;
|
|
||||||
if (typeof loggerAny.trace !== "function") {
|
|
||||||
loggerAny.trace = () => {};
|
|
||||||
}
|
|
||||||
await ensureDir(WA_WEB_AUTH_DIR);
|
|
||||||
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
|
|
||||||
const { version } = await fetchLatestBaileysVersion();
|
|
||||||
const sock = makeWASocket({
|
|
||||||
auth: {
|
|
||||||
creds: state.creds,
|
|
||||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
|
||||||
},
|
|
||||||
version,
|
|
||||||
logger,
|
|
||||||
printQRInTerminal: false,
|
|
||||||
browser: ["warelay", "cli", "0.1.3"],
|
|
||||||
syncFullHistory: false,
|
|
||||||
markOnlineOnConnect: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
sock.ev.on("creds.update", saveCreds);
|
|
||||||
sock.ev.on(
|
|
||||||
"connection.update",
|
|
||||||
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
|
|
||||||
const { connection, lastDisconnect, qr } = update;
|
|
||||||
if (qr && printQr) {
|
|
||||||
console.log("Scan this QR in WhatsApp (Linked Devices):");
|
|
||||||
qrcode.generate(qr, { small: true });
|
|
||||||
}
|
|
||||||
if (connection === "close") {
|
|
||||||
const status = getStatusCode(lastDisconnect?.error);
|
|
||||||
if (status === DisconnectReason.loggedOut) {
|
|
||||||
console.error(
|
|
||||||
danger("WhatsApp session logged out. Run: warelay login"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (connection === "open" && verbose) {
|
|
||||||
console.log(success("WhatsApp Web connected."));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return sock;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForWaConnection(
|
|
||||||
sock: ReturnType<typeof makeWASocket>,
|
|
||||||
) {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
type OffCapable = {
|
|
||||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
||||||
};
|
|
||||||
const evWithOff = sock.ev as unknown as OffCapable;
|
|
||||||
|
|
||||||
const handler = (...args: unknown[]) => {
|
|
||||||
const update = (args[0] ?? {}) as Partial<
|
|
||||||
import("@whiskeysockets/baileys").ConnectionState
|
|
||||||
>;
|
|
||||||
if (update.connection === "open") {
|
|
||||||
evWithOff.off?.("connection.update", handler);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
if (update.connection === "close") {
|
|
||||||
evWithOff.off?.("connection.update", handler);
|
|
||||||
reject(update.lastDisconnect ?? new Error("Connection closed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sock.ev.on("connection.update", handler);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendMessageWeb(
|
|
||||||
to: string,
|
|
||||||
body: string,
|
|
||||||
options: { verbose: boolean; mediaUrl?: string },
|
|
||||||
): Promise<{ messageId: string; toJid: string }> {
|
|
||||||
const sock = await createWaSocket(false, options.verbose);
|
|
||||||
try {
|
|
||||||
logInfo("🔌 Connecting to WhatsApp Web…");
|
|
||||||
await waitForWaConnection(sock);
|
|
||||||
const jid = toWhatsappJid(to);
|
|
||||||
try {
|
|
||||||
await sock.sendPresenceUpdate("composing", jid);
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`Presence update skipped: ${String(err)}`);
|
|
||||||
}
|
|
||||||
let payload: AnyMessageContent = { text: body };
|
|
||||||
if (options.mediaUrl) {
|
|
||||||
const media = await loadWebMedia(options.mediaUrl);
|
|
||||||
payload = {
|
|
||||||
image: media.buffer,
|
|
||||||
caption: body || undefined,
|
|
||||||
mimetype: media.contentType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
logInfo(
|
|
||||||
`📤 Sending via web session -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
|
|
||||||
);
|
|
||||||
const result = await sock.sendMessage(jid, payload);
|
|
||||||
const messageId = result?.key?.id ?? "unknown";
|
|
||||||
logInfo(
|
|
||||||
`✅ Sent via web session. Message ID: ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
|
|
||||||
);
|
|
||||||
return { messageId, toJid: jid };
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
sock.ws?.close();
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`Socket close failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginWeb(
|
|
||||||
verbose: boolean,
|
|
||||||
waitForConnection: typeof waitForWaConnection = waitForWaConnection,
|
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
|
||||||
) {
|
|
||||||
const sock = await createWaSocket(true, verbose);
|
|
||||||
logInfo("Waiting for WhatsApp connection...", runtime);
|
|
||||||
try {
|
|
||||||
await waitForConnection(sock);
|
|
||||||
console.log(success("✅ Linked! Credentials saved for future sends."));
|
|
||||||
} catch (err) {
|
|
||||||
const code =
|
|
||||||
(err as { error?: { output?: { statusCode?: number } } })?.error?.output
|
|
||||||
?.statusCode ??
|
|
||||||
(err as { output?: { statusCode?: number } })?.output?.statusCode;
|
|
||||||
if (code === 515) {
|
|
||||||
console.log(
|
|
||||||
info(
|
|
||||||
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
sock.ws?.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
const retry = await createWaSocket(false, verbose);
|
|
||||||
try {
|
|
||||||
await waitForConnection(retry);
|
|
||||||
console.log(
|
|
||||||
success(
|
|
||||||
"✅ Linked after restart; web session ready. You can now send with provider=web.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => retry.ws?.close(), 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (code === DisconnectReason.loggedOut) {
|
|
||||||
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
|
|
||||||
console.error(
|
|
||||||
danger(
|
|
||||||
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun warelay login and scan the QR again.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
throw new Error("Session logged out; cache cleared. Re-run login.");
|
|
||||||
}
|
|
||||||
const formatted = formatError(err);
|
|
||||||
console.error(
|
|
||||||
danger(
|
|
||||||
`WhatsApp Web connection ended before fully opening. ${formatted}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
throw new Error(formatted);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
sock.ws?.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { WA_WEB_AUTH_DIR };
|
|
||||||
|
|
||||||
export function webAuthExists() {
|
|
||||||
return fs
|
|
||||||
.access(WA_WEB_AUTH_DIR)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebInboundMessage = {
|
|
||||||
id?: string;
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
body: string;
|
|
||||||
pushName?: string;
|
|
||||||
timestamp?: number;
|
|
||||||
sendComposing: () => Promise<void>;
|
|
||||||
reply: (text: string) => Promise<void>;
|
|
||||||
sendMedia: (payload: {
|
|
||||||
image: Buffer;
|
|
||||||
caption?: string;
|
|
||||||
mimetype?: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
mediaPath?: string;
|
|
||||||
mediaType?: string;
|
|
||||||
mediaUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function monitorWebInbox(options: {
|
|
||||||
verbose: boolean;
|
|
||||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const inboundLogger = getChildLogger({ module: "web-inbound" });
|
|
||||||
const sock = await createWaSocket(false, options.verbose);
|
|
||||||
await waitForWaConnection(sock);
|
|
||||||
try {
|
|
||||||
// Advertise that the relay is online right after connecting.
|
|
||||||
await sock.sendPresenceUpdate("available");
|
|
||||||
if (isVerbose()) logVerbose("Sent global 'available' presence on connect");
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(
|
|
||||||
`Failed to send 'available' presence on connect: ${String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const selfJid = sock.user?.id;
|
|
||||||
const selfE164 = selfJid ? jidToE164(selfJid) : null;
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
sock.ev.on("messages.upsert", async (upsert) => {
|
|
||||||
if (upsert.type !== "notify") return;
|
|
||||||
for (const msg of upsert.messages) {
|
|
||||||
const id = msg.key?.id ?? undefined;
|
|
||||||
// De-dupe on message id; Baileys can emit retries.
|
|
||||||
if (id && seen.has(id)) continue;
|
|
||||||
if (id) seen.add(id);
|
|
||||||
if (msg.key?.fromMe) continue;
|
|
||||||
const remoteJid = msg.key?.remoteJid;
|
|
||||||
if (!remoteJid) continue;
|
|
||||||
// Ignore status/broadcast traffic; we only care about direct chats.
|
|
||||||
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
|
||||||
continue;
|
|
||||||
if (id) {
|
|
||||||
const participant = msg.key?.participant;
|
|
||||||
try {
|
|
||||||
await sock.readMessages([
|
|
||||||
{ remoteJid, id, participant, fromMe: false },
|
|
||||||
]);
|
|
||||||
if (isVerbose()) {
|
|
||||||
const suffix = participant ? ` (participant ${participant})` : "";
|
|
||||||
logVerbose(
|
|
||||||
`Marked message ${id} as read for ${remoteJid}${suffix}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const from = jidToE164(remoteJid);
|
|
||||||
if (!from) continue;
|
|
||||||
let body = extractText(msg.message ?? undefined);
|
|
||||||
if (!body) {
|
|
||||||
body = extractMediaPlaceholder(msg.message ?? undefined);
|
|
||||||
if (!body) continue;
|
|
||||||
}
|
|
||||||
let mediaPath: string | undefined;
|
|
||||||
let mediaType: string | undefined;
|
|
||||||
try {
|
|
||||||
const inboundMedia = await downloadInboundMedia(msg, sock);
|
|
||||||
if (inboundMedia) {
|
|
||||||
const saved = await saveMediaBuffer(
|
|
||||||
inboundMedia.buffer,
|
|
||||||
inboundMedia.mimetype,
|
|
||||||
);
|
|
||||||
mediaPath = saved.path;
|
|
||||||
mediaType = inboundMedia.mimetype;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`Inbound media download failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
const chatJid = remoteJid;
|
|
||||||
const sendComposing = async () => {
|
|
||||||
try {
|
|
||||||
await sock.sendPresenceUpdate("composing", chatJid);
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`Presence update failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const reply = async (text: string) => {
|
|
||||||
await sock.sendMessage(chatJid, { text });
|
|
||||||
};
|
|
||||||
const sendMedia = async (payload: {
|
|
||||||
image: Buffer;
|
|
||||||
caption?: string;
|
|
||||||
mimetype?: string;
|
|
||||||
}) => {
|
|
||||||
await sock.sendMessage(chatJid, payload);
|
|
||||||
};
|
|
||||||
const timestamp = msg.messageTimestamp
|
|
||||||
? Number(msg.messageTimestamp) * 1000
|
|
||||||
: undefined;
|
|
||||||
inboundLogger.info(
|
|
||||||
{
|
|
||||||
from,
|
|
||||||
to: selfE164 ?? "me",
|
|
||||||
body,
|
|
||||||
mediaPath,
|
|
||||||
mediaType,
|
|
||||||
timestamp,
|
|
||||||
},
|
|
||||||
"inbound message",
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await options.onMessage({
|
|
||||||
id,
|
|
||||||
from,
|
|
||||||
to: selfE164 ?? "me",
|
|
||||||
body,
|
|
||||||
pushName: msg.pushName ?? undefined,
|
|
||||||
timestamp,
|
|
||||||
sendComposing,
|
|
||||||
reply,
|
|
||||||
sendMedia,
|
|
||||||
mediaPath,
|
|
||||||
mediaType,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
danger(`Failed handling inbound web message: ${String(err)}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
close: async () => {
|
|
||||||
try {
|
|
||||||
sock.ws?.close();
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`Socket close failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function monitorWebProvider(
|
|
||||||
verbose: boolean,
|
|
||||||
listenerFactory = monitorWebInbox,
|
|
||||||
keepAlive = true,
|
|
||||||
replyResolver: typeof getReplyFromConfig = getReplyFromConfig,
|
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
|
||||||
) {
|
|
||||||
const replyLogger = getChildLogger({ module: "web-auto-reply" });
|
|
||||||
// Listen for inbound personal WhatsApp Web messages and auto-reply if configured.
|
|
||||||
const listener = await listenerFactory({
|
|
||||||
verbose,
|
|
||||||
onMessage: async (msg) => {
|
|
||||||
const ts = msg.timestamp
|
|
||||||
? new Date(msg.timestamp).toISOString()
|
|
||||||
: new Date().toISOString();
|
|
||||||
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
|
||||||
|
|
||||||
const replyStarted = Date.now();
|
|
||||||
const replyResult = await replyResolver(
|
|
||||||
{
|
|
||||||
Body: msg.body,
|
|
||||||
From: msg.from,
|
|
||||||
To: msg.to,
|
|
||||||
MessageSid: msg.id,
|
|
||||||
MediaPath: msg.mediaPath,
|
|
||||||
MediaUrl: msg.mediaUrl,
|
|
||||||
MediaType: msg.mediaType,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onReplyStart: msg.sendComposing,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) {
|
|
||||||
logVerbose("Skipping auto-reply: no text/media returned from resolver");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (replyResult.mediaUrl) {
|
|
||||||
logVerbose(`Web auto-reply media detected: ${replyResult.mediaUrl}`);
|
|
||||||
try {
|
|
||||||
const media = await loadWebMedia(replyResult.mediaUrl);
|
|
||||||
if (isVerbose()) {
|
|
||||||
logVerbose(
|
|
||||||
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await msg.sendMedia({
|
|
||||||
image: media.buffer,
|
|
||||||
caption: replyResult.text || undefined,
|
|
||||||
mimetype: media.contentType,
|
|
||||||
});
|
|
||||||
logInfo(
|
|
||||||
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
replyLogger.info(
|
|
||||||
{
|
|
||||||
to: msg.from,
|
|
||||||
from: msg.to,
|
|
||||||
text: replyResult.text ?? null,
|
|
||||||
mediaUrl: replyResult.mediaUrl,
|
|
||||||
mediaSizeBytes: media.buffer.length,
|
|
||||||
durationMs: Date.now() - replyStarted,
|
|
||||||
},
|
|
||||||
"auto-reply sent (media)",
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
|
|
||||||
);
|
|
||||||
if (replyResult.text) {
|
|
||||||
await msg.reply(replyResult.text);
|
|
||||||
logInfo(
|
|
||||||
`⚠️ Media skipped; sent text-only to ${msg.from}`,
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
replyLogger.info(
|
|
||||||
{
|
|
||||||
to: msg.from,
|
|
||||||
from: msg.to,
|
|
||||||
text: replyResult.text,
|
|
||||||
mediaUrl: replyResult.mediaUrl,
|
|
||||||
durationMs: Date.now() - replyStarted,
|
|
||||||
mediaSendFailed: true,
|
|
||||||
},
|
|
||||||
"auto-reply sent (text fallback)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await msg.reply(replyResult.text ?? "");
|
|
||||||
}
|
|
||||||
const durationMs = Date.now() - replyStarted;
|
|
||||||
if (isVerbose()) {
|
|
||||||
console.log(
|
|
||||||
success(
|
|
||||||
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${replyResult.mediaUrl ? ", media" : ""}, ${formatDuration(durationMs)})`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
success(
|
|
||||||
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl ? " (media)" : ""}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
replyLogger.info(
|
|
||||||
{
|
|
||||||
to: msg.from,
|
|
||||||
from: msg.to,
|
|
||||||
text: replyResult.text ?? null,
|
|
||||||
mediaUrl: replyResult.mediaUrl,
|
|
||||||
durationMs,
|
|
||||||
},
|
|
||||||
"auto-reply sent",
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
danger(
|
|
||||||
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logInfo(
|
|
||||||
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
void listener.close().finally(() => {
|
|
||||||
logInfo("👋 Web monitor stopped", runtime);
|
|
||||||
runtime.exit(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (keepAlive) {
|
|
||||||
await waitForever();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readWebSelfId() {
|
|
||||||
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
|
|
||||||
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
|
|
||||||
try {
|
|
||||||
if (!fsSync.existsSync(credsPath)) {
|
|
||||||
return { e164: null, jid: null };
|
|
||||||
}
|
|
||||||
const raw = fsSync.readFileSync(credsPath, "utf-8");
|
|
||||||
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
|
|
||||||
const jid = parsed?.me?.id ?? null;
|
|
||||||
const e164 = jid ? jidToE164(jid) : null;
|
|
||||||
return { e164, jid };
|
|
||||||
} catch {
|
|
||||||
return { e164: null, jid: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logWebSelfId(
|
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
|
||||||
includeProviderPrefix = false,
|
|
||||||
) {
|
|
||||||
// Human-friendly log of the currently linked personal web session.
|
|
||||||
const { e164, jid } = readWebSelfId();
|
|
||||||
const details =
|
|
||||||
e164 || jid
|
|
||||||
? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}`
|
|
||||||
: "unknown";
|
|
||||||
const prefix = includeProviderPrefix ? "Web Provider: " : "";
|
|
||||||
runtime.log(info(`${prefix}${details}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pickProvider(pref: Provider | "auto"): Promise<Provider> {
|
|
||||||
// Auto-select web when logged in; otherwise fall back to twilio.
|
|
||||||
if (pref !== "auto") return pref;
|
|
||||||
const hasWeb = await webAuthExists();
|
|
||||||
if (hasWeb) return "web";
|
|
||||||
return "twilio";
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractText(message: proto.IMessage | undefined): string | undefined {
|
|
||||||
if (!message) return undefined;
|
|
||||||
if (typeof message.conversation === "string" && message.conversation.trim()) {
|
|
||||||
return message.conversation.trim();
|
|
||||||
}
|
|
||||||
const extended = message.extendedTextMessage?.text;
|
|
||||||
if (extended?.trim()) return extended.trim();
|
|
||||||
const caption =
|
|
||||||
message.imageMessage?.caption ?? message.videoMessage?.caption;
|
|
||||||
if (caption?.trim()) return caption.trim();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractMediaPlaceholder(
|
|
||||||
message: proto.IMessage | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
if (!message) return undefined;
|
|
||||||
if (message.imageMessage) return "<media:image>";
|
|
||||||
if (message.videoMessage) return "<media:video>";
|
|
||||||
if (message.audioMessage) return "<media:audio>";
|
|
||||||
if (message.documentMessage) return "<media:document>";
|
|
||||||
if (message.stickerMessage) return "<media:sticker>";
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadInboundMedia(
|
|
||||||
msg: proto.IWebMessageInfo,
|
|
||||||
sock: ReturnType<typeof makeWASocket>,
|
|
||||||
): Promise<{ buffer: Buffer; mimetype?: string } | undefined> {
|
|
||||||
const message = msg.message;
|
|
||||||
if (!message) return undefined;
|
|
||||||
const mimetype =
|
|
||||||
message.imageMessage?.mimetype ??
|
|
||||||
message.videoMessage?.mimetype ??
|
|
||||||
message.documentMessage?.mimetype ??
|
|
||||||
message.audioMessage?.mimetype ??
|
|
||||||
message.stickerMessage?.mimetype ??
|
|
||||||
undefined;
|
|
||||||
if (
|
|
||||||
!message.imageMessage &&
|
|
||||||
!message.videoMessage &&
|
|
||||||
!message.documentMessage &&
|
|
||||||
!message.audioMessage &&
|
|
||||||
!message.stickerMessage
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const buffer = (await downloadMediaMessage(
|
|
||||||
msg as WAMessage,
|
|
||||||
"buffer",
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
reuploadRequest: sock.updateMediaMessage,
|
|
||||||
logger: sock.logger,
|
|
||||||
},
|
|
||||||
)) as Buffer;
|
|
||||||
return { buffer, mimetype };
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWebMedia(
|
|
||||||
mediaUrl: string,
|
|
||||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
|
||||||
const MAX_WEB_BYTES = 16 * 1024 * 1024; // 16MB: web provider can handle larger than Twilio
|
|
||||||
if (mediaUrl.startsWith("file://")) {
|
|
||||||
mediaUrl = mediaUrl.replace("file://", "");
|
|
||||||
}
|
|
||||||
if (/^https?:\/\//i.test(mediaUrl)) {
|
|
||||||
const res = await fetch(mediaUrl);
|
|
||||||
if (!res.ok || !res.body) {
|
|
||||||
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const array = Buffer.from(await res.arrayBuffer());
|
|
||||||
if (array.length > MAX_WEB_BYTES) {
|
|
||||||
throw new Error(
|
|
||||||
`Media exceeds ${Math.floor(MAX_WEB_BYTES / (1024 * 1024))}MB limit (got ${(
|
|
||||||
array.length / (1024 * 1024)
|
|
||||||
).toFixed(1)}MB)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
buffer: array,
|
|
||||||
contentType: res.headers.get("content-type") ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Local path
|
|
||||||
const data = await fs.readFile(mediaUrl);
|
|
||||||
if (data.length > MAX_WEB_BYTES) {
|
|
||||||
throw new Error(
|
|
||||||
`Media exceeds ${Math.floor(MAX_WEB_BYTES / (1024 * 1024))}MB limit (got ${(
|
|
||||||
data.length / (1024 * 1024)
|
|
||||||
).toFixed(1)}MB)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { buffer: data };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusCode(err: unknown) {
|
|
||||||
return (
|
|
||||||
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
|
|
||||||
(err as { status?: number })?.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatError(err: unknown): string {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === "string") return err;
|
|
||||||
const status = getStatusCode(err);
|
|
||||||
const code = (err as { code?: unknown })?.code;
|
|
||||||
if (status || code)
|
|
||||||
return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
|
|
||||||
return String(err);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
export { createClient } from "../../twilio/client.js";
|
export { createClient } from "../../twilio/client.js";
|
||||||
export {
|
export {
|
||||||
formatMessageLine,
|
formatMessageLine,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
} from "../../twilio/messages.js";
|
} from "../../twilio/messages.js";
|
||||||
export { monitorTwilio } from "../../twilio/monitor.js";
|
export { monitorTwilio } from "../../twilio/monitor.js";
|
||||||
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
||||||
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
||||||
export { sendTypingIndicator } from "../../twilio/typing.js";
|
export { sendTypingIndicator } from "../../twilio/typing.js";
|
||||||
export {
|
export {
|
||||||
findIncomingNumberSid,
|
findIncomingNumberSid,
|
||||||
findMessagingServiceSid,
|
findMessagingServiceSid,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
} from "../../twilio/update-webhook.js";
|
} from "../../twilio/update-webhook.js";
|
||||||
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";
|
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";
|
||||||
@@ -4,16 +4,16 @@ import * as impl from "../../provider-web.js";
|
|||||||
import * as entry from "./index.js";
|
import * as entry from "./index.js";
|
||||||
|
|
||||||
describe("providers/web entrypoint", () => {
|
describe("providers/web entrypoint", () => {
|
||||||
it("re-exports web provider helpers", () => {
|
it("re-exports web provider helpers", () => {
|
||||||
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
expect(entry.createWaSocket).toBe(impl.createWaSocket);
|
||||||
expect(entry.loginWeb).toBe(impl.loginWeb);
|
expect(entry.loginWeb).toBe(impl.loginWeb);
|
||||||
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
expect(entry.logWebSelfId).toBe(impl.logWebSelfId);
|
||||||
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||||
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||||
expect(entry.pickProvider).toBe(impl.pickProvider);
|
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||||
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
||||||
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||||
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||||
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+10
-10
@@ -1,13 +1,13 @@
|
|||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
export {
|
export {
|
||||||
createWaSocket,
|
createWaSocket,
|
||||||
loginWeb,
|
loginWeb,
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
monitorWebInbox,
|
monitorWebInbox,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
sendMessageWeb,
|
sendMessageWeb,
|
||||||
WA_WEB_AUTH_DIR,
|
WA_WEB_AUTH_DIR,
|
||||||
waitForWaConnection,
|
waitForWaConnection,
|
||||||
webAuthExists,
|
webAuthExists,
|
||||||
} from "../../provider-web.js";
|
} from "../../provider-web.js";
|
||||||
+9
-9
@@ -1,14 +1,14 @@
|
|||||||
export type RuntimeEnv = {
|
export type RuntimeEnv = {
|
||||||
log: typeof console.log;
|
log: typeof console.log;
|
||||||
error: typeof console.error;
|
error: typeof console.error;
|
||||||
exit: (code: number) => never;
|
exit: (code: number) => never;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultRuntime: RuntimeEnv = {
|
export const defaultRuntime: RuntimeEnv = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
error: console.error,
|
error: console.error,
|
||||||
exit: (code) => {
|
exit: (code) => {
|
||||||
process.exit(code);
|
process.exit(code);
|
||||||
throw new Error("unreachable"); // satisfies tests when mocked
|
throw new Error("unreachable"); // satisfies tests when mocked
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2,13 +2,13 @@ import Twilio from "twilio";
|
|||||||
import type { EnvConfig } from "../env.js";
|
import type { EnvConfig } from "../env.js";
|
||||||
|
|
||||||
export function createClient(env: EnvConfig) {
|
export function createClient(env: EnvConfig) {
|
||||||
// Twilio client using either auth token or API key/secret.
|
// Twilio client using either auth token or API key/secret.
|
||||||
if ("authToken" in env.auth) {
|
if ("authToken" in env.auth) {
|
||||||
return Twilio(env.accountSid, env.auth.authToken, {
|
return Twilio(env.accountSid, env.auth.authToken, {
|
||||||
accountSid: env.accountSid,
|
accountSid: env.accountSid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
||||||
accountSid: env.accountSid,
|
accountSid: env.accountSid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+73
-73
@@ -3,97 +3,97 @@ import { withWhatsAppPrefix } from "../utils.js";
|
|||||||
import { createClient } from "./client.js";
|
import { createClient } from "./client.js";
|
||||||
|
|
||||||
export type ListedMessage = {
|
export type ListedMessage = {
|
||||||
sid: string;
|
sid: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
dateCreated: Date | undefined;
|
dateCreated: Date | undefined;
|
||||||
from?: string | null;
|
from?: string | null;
|
||||||
to?: string | null;
|
to?: string | null;
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
errorCode: number | null;
|
errorCode: number | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove duplicates by SID while preserving order.
|
// Remove duplicates by SID while preserving order.
|
||||||
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped: ListedMessage[] = [];
|
const deduped: ListedMessage[] = [];
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (seen.has(m.sid)) continue;
|
if (seen.has(m.sid)) continue;
|
||||||
seen.add(m.sid);
|
seen.add(m.sid);
|
||||||
deduped.push(m);
|
deduped.push(m);
|
||||||
}
|
}
|
||||||
return deduped;
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort messages newest -> oldest by dateCreated.
|
// Sort messages newest -> oldest by dateCreated.
|
||||||
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
||||||
return [...messages].sort((a, b) => {
|
return [...messages].sort((a, b) => {
|
||||||
const da = a.dateCreated?.getTime() ?? 0;
|
const da = a.dateCreated?.getTime() ?? 0;
|
||||||
const db = b.dateCreated?.getTime() ?? 0;
|
const db = b.dateCreated?.getTime() ?? 0;
|
||||||
return db - da;
|
return db - da;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
||||||
export async function listRecentMessages(
|
export async function listRecentMessages(
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
): Promise<ListedMessage[]> {
|
): Promise<ListedMessage[]> {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const client = clientOverride ?? createClient(env);
|
const client = clientOverride ?? createClient(env);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||||
|
|
||||||
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
||||||
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
||||||
const inbound = await client.messages.list({
|
const inbound = await client.messages.list({
|
||||||
to: from,
|
to: from,
|
||||||
dateSentAfter: since,
|
dateSentAfter: since,
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
});
|
});
|
||||||
const outbound = await client.messages.list({
|
const outbound = await client.messages.list({
|
||||||
from,
|
from,
|
||||||
dateSentAfter: since,
|
dateSentAfter: since,
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||||
const combined = uniqueBySid(
|
const combined = uniqueBySid(
|
||||||
[...inboundArr, ...outboundArr].map((m) => ({
|
[...inboundArr, ...outboundArr].map((m) => ({
|
||||||
sid: m.sid,
|
sid: m.sid,
|
||||||
status: m.status ?? null,
|
status: m.status ?? null,
|
||||||
direction: m.direction ?? null,
|
direction: m.direction ?? null,
|
||||||
dateCreated: m.dateCreated,
|
dateCreated: m.dateCreated,
|
||||||
from: m.from,
|
from: m.from,
|
||||||
to: m.to,
|
to: m.to,
|
||||||
body: m.body,
|
body: m.body,
|
||||||
errorCode: m.errorCode ?? null,
|
errorCode: m.errorCode ?? null,
|
||||||
errorMessage: m.errorMessage ?? null,
|
errorMessage: m.errorMessage ?? null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
return sortByDateDesc(combined).slice(0, limit);
|
return sortByDateDesc(combined).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Human-friendly single-line formatter for recent messages.
|
// Human-friendly single-line formatter for recent messages.
|
||||||
export function formatMessageLine(m: ListedMessage): string {
|
export function formatMessageLine(m: ListedMessage): string {
|
||||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||||
const dir =
|
const dir =
|
||||||
m.direction === "inbound"
|
m.direction === "inbound"
|
||||||
? "⬅️ "
|
? "⬅️ "
|
||||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||||
? "➡️ "
|
? "➡️ "
|
||||||
: "↔️ ";
|
: "↔️ ";
|
||||||
const status = m.status ?? "unknown";
|
const status = m.status ?? "unknown";
|
||||||
const err =
|
const err =
|
||||||
m.errorCode != null
|
m.errorCode != null
|
||||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||||
: "";
|
: "";
|
||||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||||
const bodyPreview =
|
const bodyPreview =
|
||||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||||
}
|
}
|
||||||
+37
-37
@@ -3,43 +3,43 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { monitorTwilio } from "./monitor.js";
|
import { monitorTwilio } from "./monitor.js";
|
||||||
|
|
||||||
describe("monitorTwilio", () => {
|
describe("monitorTwilio", () => {
|
||||||
it("processes inbound messages once with injected deps", async () => {
|
it("processes inbound messages once with injected deps", async () => {
|
||||||
const listRecentMessages = vi.fn().mockResolvedValue([
|
const listRecentMessages = vi.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
sid: "m1",
|
sid: "m1",
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
dateCreated: new Date(),
|
dateCreated: new Date(),
|
||||||
from: "+1",
|
from: "+1",
|
||||||
to: "+2",
|
to: "+2",
|
||||||
body: "hi",
|
body: "hi",
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
status: null,
|
status: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
||||||
const readEnv = vi.fn(() => ({
|
const readEnv = vi.fn(() => ({
|
||||||
accountSid: "AC",
|
accountSid: "AC",
|
||||||
whatsappFrom: "whatsapp:+1",
|
whatsappFrom: "whatsapp:+1",
|
||||||
auth: { accountSid: "AC", authToken: "t" },
|
auth: { accountSid: "AC", authToken: "t" },
|
||||||
}));
|
}));
|
||||||
const createClient = vi.fn(
|
const createClient = vi.fn(
|
||||||
() => ({ messages: { create: vi.fn() } }) as never,
|
() => ({ messages: { create: vi.fn() } }) as never,
|
||||||
);
|
);
|
||||||
const sleep = vi.fn().mockResolvedValue(undefined);
|
const sleep = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
await monitorTwilio(0, 0, {
|
await monitorTwilio(0, 0, {
|
||||||
deps: {
|
deps: {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages,
|
listRecentMessages,
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
},
|
},
|
||||||
maxIterations: 1,
|
maxIterations: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
||||||
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+96
-96
@@ -8,122 +8,122 @@ import { sleep, withWhatsAppPrefix } from "../utils.js";
|
|||||||
import { createClient } from "./client.js";
|
import { createClient } from "./client.js";
|
||||||
|
|
||||||
type MonitorDeps = {
|
type MonitorDeps = {
|
||||||
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||||
listRecentMessages: (
|
listRecentMessages: (
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
) => Promise<ListedMessage[]>;
|
) => Promise<ListedMessage[]>;
|
||||||
readEnv: typeof readEnv;
|
readEnv: typeof readEnv;
|
||||||
createClient: typeof createClient;
|
createClient: typeof createClient;
|
||||||
sleep: typeof sleep;
|
sleep: typeof sleep;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||||
|
|
||||||
export type ListedMessage = {
|
export type ListedMessage = {
|
||||||
sid: string;
|
sid: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
dateCreated: Date | undefined;
|
dateCreated: Date | undefined;
|
||||||
from?: string | null;
|
from?: string | null;
|
||||||
to?: string | null;
|
to?: string | null;
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
errorCode: number | null;
|
errorCode: number | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MonitorOptions = {
|
type MonitorOptions = {
|
||||||
client?: ReturnType<typeof createClient>;
|
client?: ReturnType<typeof createClient>;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
deps?: MonitorDeps;
|
deps?: MonitorDeps;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultDeps: MonitorDeps = {
|
const defaultDeps: MonitorDeps = {
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
listRecentMessages: () => Promise.resolve([]),
|
listRecentMessages: () => Promise.resolve([]),
|
||||||
readEnv,
|
readEnv,
|
||||||
createClient,
|
createClient,
|
||||||
sleep,
|
sleep,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Poll Twilio for inbound messages and auto-reply when configured.
|
// Poll Twilio for inbound messages and auto-reply when configured.
|
||||||
export async function monitorTwilio(
|
export async function monitorTwilio(
|
||||||
pollSeconds: number,
|
pollSeconds: number,
|
||||||
lookbackMinutes: number,
|
lookbackMinutes: number,
|
||||||
opts?: MonitorOptions,
|
opts?: MonitorOptions,
|
||||||
) {
|
) {
|
||||||
const deps = opts?.deps ?? defaultDeps;
|
const deps = opts?.deps ?? defaultDeps;
|
||||||
const runtime = opts?.runtime ?? defaultRuntime;
|
const runtime = opts?.runtime ?? defaultRuntime;
|
||||||
const maxIterations = opts?.maxIterations ?? Infinity;
|
const maxIterations = opts?.maxIterations ?? Infinity;
|
||||||
let backoffMs = 1_000;
|
let backoffMs = 1_000;
|
||||||
|
|
||||||
const env = deps.readEnv(runtime);
|
const env = deps.readEnv(runtime);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const client = opts?.client ?? deps.createClient(env);
|
const client = opts?.client ?? deps.createClient(env);
|
||||||
logInfo(
|
logInfo(
|
||||||
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
let lastSeenSid: string | undefined;
|
let lastSeenSid: string | undefined;
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
while (iterations < maxIterations) {
|
while (iterations < maxIterations) {
|
||||||
let messages: ListedMessage[] = [];
|
let messages: ListedMessage[] = [];
|
||||||
try {
|
try {
|
||||||
messages =
|
messages =
|
||||||
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||||
backoffMs = 1_000; // reset after success
|
backoffMs = 1_000; // reset after success
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logWarn(
|
logWarn(
|
||||||
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
await deps.sleep(backoffMs);
|
await deps.sleep(backoffMs);
|
||||||
backoffMs = Math.min(backoffMs * 2, 10_000);
|
backoffMs = Math.min(backoffMs * 2, 10_000);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
||||||
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
||||||
const newestFirst = [...inboundOnly].sort(
|
const newestFirst = [...inboundOnly].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
||||||
);
|
);
|
||||||
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
||||||
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||||
iterations += 1;
|
iterations += 1;
|
||||||
if (iterations >= maxIterations) break;
|
if (iterations >= maxIterations) break;
|
||||||
await deps.sleep(
|
await deps.sleep(
|
||||||
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessages(
|
async function handleMessages(
|
||||||
messages: ListedMessage[],
|
messages: ListedMessage[],
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
lastSeenSid: string | undefined,
|
lastSeenSid: string | undefined,
|
||||||
deps: MonitorDeps,
|
deps: MonitorDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (!m.sid) continue;
|
if (!m.sid) continue;
|
||||||
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
||||||
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
||||||
if (m.direction !== "inbound") continue;
|
if (m.direction !== "inbound") continue;
|
||||||
if (!m.from || !m.to) continue;
|
if (!m.from || !m.to) continue;
|
||||||
try {
|
try {
|
||||||
await deps.autoReplyIfConfigured(
|
await deps.autoReplyIfConfigured(
|
||||||
client as unknown as import("./types.js").TwilioRequester & {
|
client as unknown as import("./types.js").TwilioRequester & {
|
||||||
messages: { create: (opts: unknown) => Promise<unknown> };
|
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||||
},
|
},
|
||||||
m as unknown as MessageInstance,
|
m as unknown as MessageInstance,
|
||||||
undefined,
|
undefined,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+25
-25
@@ -3,30 +3,30 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { waitForFinalStatus } from "./send.js";
|
import { waitForFinalStatus } from "./send.js";
|
||||||
|
|
||||||
describe("twilio send helpers", () => {
|
describe("twilio send helpers", () => {
|
||||||
it("waitForFinalStatus resolves on delivered", async () => {
|
it("waitForFinalStatus resolves on delivered", async () => {
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ status: "queued" })
|
.mockResolvedValueOnce({ status: "queued" })
|
||||||
.mockResolvedValueOnce({ status: "delivered" });
|
.mockResolvedValueOnce({ status: "delivered" });
|
||||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||||
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
||||||
expect(fetch).toHaveBeenCalledTimes(2);
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waitForFinalStatus exits on failure", async () => {
|
it("waitForFinalStatus exits on failure", async () => {
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
.mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||||
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
error: () => {},
|
error: () => {},
|
||||||
exit: vi.fn(() => {
|
exit: vi.fn(() => {
|
||||||
throw new Error("exit");
|
throw new Error("exit");
|
||||||
}),
|
}),
|
||||||
} as never;
|
} as never;
|
||||||
await expect(
|
await expect(
|
||||||
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
waitForFinalStatus(client, "SM1", 1, 0.01, runtime),
|
||||||
).rejects.toBeInstanceOf(Error);
|
).rejects.toBeInstanceOf(Error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+48
-48
@@ -10,60 +10,60 @@ const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
|
|||||||
|
|
||||||
// Send outbound WhatsApp message; exit non-zero on API failure.
|
// Send outbound WhatsApp message; exit non-zero on API failure.
|
||||||
export async function sendMessage(
|
export async function sendMessage(
|
||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
opts?: { mediaUrl?: string },
|
opts?: { mediaUrl?: string },
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
const client = createClient(env);
|
const client = createClient(env);
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
const toNumber = withWhatsAppPrefix(to);
|
const toNumber = withWhatsAppPrefix(to);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await client.messages.create({
|
const message = await client.messages.create({
|
||||||
from,
|
from,
|
||||||
to: toNumber,
|
to: toNumber,
|
||||||
body,
|
body,
|
||||||
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return { client, sid: message.sid };
|
return { client, sid: message.sid };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTwilioSendError(err, toNumber, runtime);
|
logTwilioSendError(err, toNumber, runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll message status until delivered/failed or timeout.
|
// Poll message status until delivered/failed or timeout.
|
||||||
export async function waitForFinalStatus(
|
export async function waitForFinalStatus(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
sid: string,
|
sid: string,
|
||||||
timeoutSeconds: number,
|
timeoutSeconds: number,
|
||||||
pollSeconds: number,
|
pollSeconds: number,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const m = await client.messages(sid).fetch();
|
const m = await client.messages(sid).fetch();
|
||||||
const status = m.status ?? "unknown";
|
const status = m.status ?? "unknown";
|
||||||
if (successTerminalStatuses.has(status)) {
|
if (successTerminalStatuses.has(status)) {
|
||||||
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (failureTerminalStatuses.has(status)) {
|
if (failureTerminalStatuses.has(status)) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
await sleep(pollSeconds * 1000);
|
await sleep(pollSeconds * 1000);
|
||||||
}
|
}
|
||||||
logInfo(
|
logInfo(
|
||||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+45
-45
@@ -4,50 +4,50 @@ import { withWhatsAppPrefix } from "../utils.js";
|
|||||||
import type { TwilioSenderListClient } from "./types.js";
|
import type { TwilioSenderListClient } from "./types.js";
|
||||||
|
|
||||||
export async function findWhatsappSenderSid(
|
export async function findWhatsappSenderSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
from: string,
|
from: string,
|
||||||
explicitSenderSid?: string,
|
explicitSenderSid?: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
||||||
if (explicitSenderSid) {
|
if (explicitSenderSid) {
|
||||||
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
||||||
return explicitSenderSid;
|
return explicitSenderSid;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Prefer official SDK list helper to avoid request-shape mismatches.
|
// Prefer official SDK list helper to avoid request-shape mismatches.
|
||||||
// Twilio helper types are broad; we narrow to expected shape.
|
// Twilio helper types are broad; we narrow to expected shape.
|
||||||
const senderClient = client as unknown as TwilioSenderListClient;
|
const senderClient = client as unknown as TwilioSenderListClient;
|
||||||
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
if (!senders) {
|
if (!senders) {
|
||||||
throw new Error('List senders response missing "senders" array');
|
throw new Error('List senders response missing "senders" array');
|
||||||
}
|
}
|
||||||
const match = senders.find(
|
const match = senders.find(
|
||||||
(s) =>
|
(s) =>
|
||||||
(typeof s.senderId === "string" &&
|
(typeof s.senderId === "string" &&
|
||||||
s.senderId === withWhatsAppPrefix(from)) ||
|
s.senderId === withWhatsAppPrefix(from)) ||
|
||||||
(typeof s.sender_id === "string" &&
|
(typeof s.sender_id === "string" &&
|
||||||
s.sender_id === withWhatsAppPrefix(from)),
|
s.sender_id === withWhatsAppPrefix(from)),
|
||||||
);
|
);
|
||||||
if (!match || typeof match.sid !== "string") {
|
if (!match || typeof match.sid !== "string") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return match.sid;
|
return match.sid;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
runtime.error(
|
runtime.error(
|
||||||
info(
|
info(
|
||||||
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+51
-51
@@ -1,79 +1,79 @@
|
|||||||
export type TwilioRequestOptions = {
|
export type TwilioRequestOptions = {
|
||||||
method: "get" | "post";
|
method: "get" | "post";
|
||||||
uri: string;
|
uri: string;
|
||||||
params?: Record<string, string | number>;
|
params?: Record<string, string | number>;
|
||||||
form?: Record<string, string>;
|
form?: Record<string, string>;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioSender = { sid: string; sender_id: string };
|
export type TwilioSender = { sid: string; sender_id: string };
|
||||||
|
|
||||||
export type TwilioRequestResponse = {
|
export type TwilioRequestResponse = {
|
||||||
data?: {
|
data?: {
|
||||||
senders?: TwilioSender[];
|
senders?: TwilioSender[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingNumber = {
|
export type IncomingNumber = {
|
||||||
sid: string;
|
sid: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
smsUrl?: string;
|
smsUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioChannelsSender = {
|
export type TwilioChannelsSender = {
|
||||||
sid?: string;
|
sid?: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
sender_id?: string;
|
sender_id?: string;
|
||||||
webhook?: {
|
webhook?: {
|
||||||
callback_url?: string;
|
callback_url?: string;
|
||||||
callback_method?: string;
|
callback_method?: string;
|
||||||
fallback_url?: string;
|
fallback_url?: string;
|
||||||
fallback_method?: string;
|
fallback_method?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelSenderUpdater = {
|
export type ChannelSenderUpdater = {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingPhoneNumberUpdater = {
|
export type IncomingPhoneNumberUpdater = {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncomingPhoneNumbersClient = {
|
export type IncomingPhoneNumbersClient = {
|
||||||
list: (params: {
|
list: (params: {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}) => Promise<IncomingNumber[]>;
|
}) => Promise<IncomingNumber[]>;
|
||||||
get: (sid: string) => IncomingPhoneNumberUpdater;
|
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||||
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
||||||
|
|
||||||
export type TwilioSenderListClient = {
|
export type TwilioSenderListClient = {
|
||||||
messaging: {
|
messaging: {
|
||||||
v2: {
|
v2: {
|
||||||
channelsSenders: {
|
channelsSenders: {
|
||||||
list: (params: {
|
list: (params: {
|
||||||
channel: string;
|
channel: string;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}) => Promise<TwilioChannelsSender[]>;
|
}) => Promise<TwilioChannelsSender[]>;
|
||||||
(
|
(
|
||||||
sid: string,
|
sid: string,
|
||||||
): ChannelSenderUpdater & {
|
): ChannelSenderUpdater & {
|
||||||
fetch: () => Promise<TwilioChannelsSender>;
|
fetch: () => Promise<TwilioChannelsSender>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
v1: {
|
v1: {
|
||||||
services: (sid: string) => {
|
services: (sid: string) => {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TwilioRequester = {
|
export type TwilioRequester = {
|
||||||
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
||||||
};
|
};
|
||||||
+31
-31
@@ -2,42 +2,42 @@ import { isVerbose, logVerbose, warn } from "../globals.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
type TwilioRequestOptions = {
|
type TwilioRequestOptions = {
|
||||||
method: "get" | "post";
|
method: "get" | "post";
|
||||||
uri: string;
|
uri: string;
|
||||||
params?: Record<string, string | number>;
|
params?: Record<string, string | number>;
|
||||||
form?: Record<string, string>;
|
form?: Record<string, string>;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TwilioRequester = {
|
type TwilioRequester = {
|
||||||
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function sendTypingIndicator(
|
export async function sendTypingIndicator(
|
||||||
client: TwilioRequester,
|
client: TwilioRequester,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
messageSid?: string,
|
messageSid?: string,
|
||||||
) {
|
) {
|
||||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||||
if (!messageSid) {
|
if (!messageSid) {
|
||||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.request({
|
await client.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
||||||
form: {
|
form: {
|
||||||
messageId: messageSid,
|
messageId: messageSid,
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
||||||
runtime.error(err as Error);
|
runtime.error(err as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,61 +1,61 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findIncomingNumberSid,
|
findIncomingNumberSid,
|
||||||
findMessagingServiceSid,
|
findMessagingServiceSid,
|
||||||
setMessagingServiceWebhook,
|
setMessagingServiceWebhook,
|
||||||
} from "./update-webhook.js";
|
} from "./update-webhook.js";
|
||||||
|
|
||||||
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
||||||
|
|
||||||
describe("update-webhook helpers", () => {
|
describe("update-webhook helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.TWILIO_ACCOUNT_SID = "AC";
|
process.env.TWILIO_ACCOUNT_SID = "AC";
|
||||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
||||||
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.entries(envBackup).forEach(([k, v]) => {
|
Object.entries(envBackup).forEach(([k, v]) => {
|
||||||
if (v === undefined) delete process.env[k];
|
if (v === undefined) delete process.env[k];
|
||||||
else process.env[k] = v;
|
else process.env[k] = v;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("findIncomingNumberSid returns first match", async () => {
|
it("findIncomingNumberSid returns first match", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const sid = await findIncomingNumberSid(client);
|
const sid = await findIncomingNumberSid(client);
|
||||||
expect(sid).toBe("PN1");
|
expect(sid).toBe("PN1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const sid = await findMessagingServiceSid(client);
|
const sid = await findMessagingServiceSid(client);
|
||||||
expect(sid).toBe("MG1");
|
expect(sid).toBe("MG1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setMessagingServiceWebhook updates via service helper", async () => {
|
it("setMessagingServiceWebhook updates via service helper", async () => {
|
||||||
const update = async (_: unknown) => {};
|
const update = async (_: unknown) => {};
|
||||||
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
||||||
const client = {
|
const client = {
|
||||||
messaging: {
|
messaging: {
|
||||||
v1: {
|
v1: {
|
||||||
services: () => ({ update, fetch }),
|
services: () => ({ update, fetch }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: {
|
||||||
list: async () => [{ messagingServiceSid: "MG1" }],
|
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||||
},
|
},
|
||||||
} as never;
|
} as never;
|
||||||
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+170
-170
@@ -6,193 +6,193 @@ import type { createClient } from "./client.js";
|
|||||||
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
|
import type { TwilioRequester, TwilioSenderListClient } from "./types.js";
|
||||||
|
|
||||||
export async function findIncomingNumberSid(
|
export async function findIncomingNumberSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Look up incoming phone number SID matching the configured WhatsApp number.
|
// Look up incoming phone number SID matching the configured WhatsApp number.
|
||||||
try {
|
try {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
const list = await client.incomingPhoneNumbers.list({
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
return list?.[0]?.sid ?? null;
|
return list?.[0]?.sid ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findMessagingServiceSid(
|
export async function findMessagingServiceSid(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
||||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||||
try {
|
try {
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
const list = await client.incomingPhoneNumbers.list({
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
const msid =
|
const msid =
|
||||||
(list?.[0] as IncomingNumberWithService | undefined)
|
(list?.[0] as IncomingNumberWithService | undefined)
|
||||||
?.messagingServiceSid ?? null;
|
?.messagingServiceSid ?? null;
|
||||||
return msid;
|
return msid;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setMessagingServiceWebhook(
|
export async function setMessagingServiceWebhook(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
url: string,
|
url: string,
|
||||||
method: "POST" | "GET",
|
method: "POST" | "GET",
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const msid = await findMessagingServiceSid(client);
|
const msid = await findMessagingServiceSid(client);
|
||||||
if (!msid) return false;
|
if (!msid) return false;
|
||||||
try {
|
try {
|
||||||
await client.messaging.v1.services(msid).update({
|
await client.messaging.v1.services(msid).update({
|
||||||
InboundRequestUrl: url,
|
InboundRequestUrl: url,
|
||||||
InboundRequestMethod: method,
|
InboundRequestMethod: method,
|
||||||
});
|
});
|
||||||
const fetched = await client.messaging.v1.services(msid).fetch();
|
const fetched = await client.messaging.v1.services(msid).fetch();
|
||||||
const stored = fetched?.inboundRequestUrl;
|
const stored = fetched?.inboundRequestUrl;
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
||||||
export async function updateWebhook(
|
export async function updateWebhook(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
senderSid: string,
|
senderSid: string,
|
||||||
url: string,
|
url: string,
|
||||||
method: "POST" | "GET" = "POST",
|
method: "POST" | "GET" = "POST",
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
// Point Twilio sender webhook at the provided URL.
|
// Point Twilio sender webhook at the provided URL.
|
||||||
const requester = client as unknown as TwilioRequester;
|
const requester = client as unknown as TwilioRequester;
|
||||||
const clientTyped = client as unknown as TwilioSenderListClient;
|
const clientTyped = client as unknown as TwilioSenderListClient;
|
||||||
|
|
||||||
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
||||||
try {
|
try {
|
||||||
await requester.request({
|
await requester.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||||
body: {
|
body: {
|
||||||
webhook: {
|
webhook: {
|
||||||
callback_url: url,
|
callback_url: url,
|
||||||
callback_method: method,
|
callback_method: method,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
if (storedUrl) {
|
if (storedUrl) {
|
||||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1b) Form-encoded fallback for older Twilio stacks
|
// 1b) Form-encoded fallback for older Twilio stacks
|
||||||
try {
|
try {
|
||||||
await requester.request({
|
await requester.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||||
form: {
|
form: {
|
||||||
"Webhook.CallbackUrl": url,
|
"Webhook.CallbackUrl": url,
|
||||||
"Webhook.CallbackMethod": method,
|
"Webhook.CallbackMethod": method,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
if (storedUrl) {
|
if (storedUrl) {
|
||||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) SDK helper fallback (if supported by this client)
|
// 2) SDK helper fallback (if supported by this client)
|
||||||
try {
|
try {
|
||||||
if (clientTyped.messaging?.v2?.channelsSenders) {
|
if (clientTyped.messaging?.v2?.channelsSenders) {
|
||||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||||
callbackUrl: url,
|
callbackUrl: url,
|
||||||
callbackMethod: method,
|
callbackMethod: method,
|
||||||
});
|
});
|
||||||
const fetched = await clientTyped.messaging.v2
|
const fetched = await clientTyped.messaging.v2
|
||||||
.channelsSenders(senderSid)
|
.channelsSenders(senderSid)
|
||||||
.fetch();
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Incoming phone number fallback (works for many WA senders)
|
// 3) Incoming phone number fallback (works for many WA senders)
|
||||||
try {
|
try {
|
||||||
const phoneSid = await findIncomingNumberSid(clientTyped);
|
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||||
if (phoneSid) {
|
if (phoneSid) {
|
||||||
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
||||||
smsUrl: url,
|
smsUrl: url,
|
||||||
smsMethod: method,
|
smsMethod: method,
|
||||||
});
|
});
|
||||||
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isVerbose())
|
if (isVerbose())
|
||||||
logError(
|
logError(
|
||||||
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+25
-25
@@ -2,36 +2,36 @@ import { danger, info } from "../globals.js";
|
|||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
type TwilioApiError = {
|
type TwilioApiError = {
|
||||||
code?: number | string;
|
code?: number | string;
|
||||||
status?: number | string;
|
status?: number | string;
|
||||||
message?: string;
|
message?: string;
|
||||||
moreInfo?: string;
|
moreInfo?: string;
|
||||||
response?: { body?: unknown };
|
response?: { body?: unknown };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatTwilioError(err: unknown): string {
|
export function formatTwilioError(err: unknown): string {
|
||||||
// Normalize Twilio error objects into a single readable string.
|
// Normalize Twilio error objects into a single readable string.
|
||||||
const e = err as TwilioApiError;
|
const e = err as TwilioApiError;
|
||||||
const pieces = [];
|
const pieces = [];
|
||||||
if (e.code != null) pieces.push(`code ${e.code}`);
|
if (e.code != null) pieces.push(`code ${e.code}`);
|
||||||
if (e.status != null) pieces.push(`status ${e.status}`);
|
if (e.status != null) pieces.push(`status ${e.status}`);
|
||||||
if (e.message) pieces.push(e.message);
|
if (e.message) pieces.push(e.message);
|
||||||
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
||||||
return pieces.length ? pieces.join(" | ") : String(err);
|
return pieces.length ? pieces.join(" | ") : String(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logTwilioSendError(
|
export function logTwilioSendError(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
destination?: string,
|
destination?: string,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Friendly error logger for send failures, including response body when present.
|
// Friendly error logger for send failures, including response body when present.
|
||||||
const prefix = destination ? `to ${destination}: ` : "";
|
const prefix = destination ? `to ${destination}: ` : "";
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
||||||
);
|
);
|
||||||
const body = (err as TwilioApiError)?.response?.body;
|
const body = (err as TwilioApiError)?.response?.body;
|
||||||
if (body) {
|
if (body) {
|
||||||
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+122
-122
@@ -16,143 +16,143 @@ import { logTwilioSendError } from "./utils.js";
|
|||||||
|
|
||||||
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
||||||
export async function startWebhook(
|
export async function startWebhook(
|
||||||
port: number,
|
port: number,
|
||||||
path = "/webhook/whatsapp",
|
path = "/webhook/whatsapp",
|
||||||
autoReply: string | undefined,
|
autoReply: string | undefined,
|
||||||
verbose: boolean,
|
verbose: boolean,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<Server> {
|
): Promise<Server> {
|
||||||
const normalizedPath = normalizePath(path);
|
const normalizedPath = normalizePath(path);
|
||||||
const env = readEnv(runtime);
|
const env = readEnv(runtime);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
attachMediaRoutes(app, undefined, runtime);
|
attachMediaRoutes(app, undefined, runtime);
|
||||||
// Twilio sends application/x-www-form-urlencoded payloads.
|
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||||
app.use(bodyParser.urlencoded({ extended: false }));
|
app.use(bodyParser.urlencoded({ extended: false }));
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||||
runtime.log(`
|
runtime.log(`
|
||||||
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
||||||
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||||
|
|
||||||
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||||
let mediaPath: string | undefined;
|
let mediaPath: string | undefined;
|
||||||
let mediaUrlInbound: string | undefined;
|
let mediaUrlInbound: string | undefined;
|
||||||
let mediaType: string | undefined;
|
let mediaType: string | undefined;
|
||||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||||
mediaType =
|
mediaType =
|
||||||
typeof req.body?.MediaContentType0 === "string"
|
typeof req.body?.MediaContentType0 === "string"
|
||||||
? (req.body.MediaContentType0 as string)
|
? (req.body.MediaContentType0 as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const creds = buildTwilioBasicAuth(env);
|
const creds = buildTwilioBasicAuth(env);
|
||||||
const saved = await saveMediaSource(
|
const saved = await saveMediaSource(
|
||||||
mediaUrlInbound,
|
mediaUrlInbound,
|
||||||
{
|
{
|
||||||
Authorization: `Basic ${creds}`,
|
Authorization: `Basic ${creds}`,
|
||||||
},
|
},
|
||||||
"inbound",
|
"inbound",
|
||||||
);
|
);
|
||||||
mediaPath = saved.path;
|
mediaPath = saved.path;
|
||||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(`Failed to download inbound media: ${String(err)}`),
|
danger(`Failed to download inbound media: ${String(err)}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createClient(env);
|
const client = createClient(env);
|
||||||
let replyResult: ReplyPayload | undefined =
|
let replyResult: ReplyPayload | undefined =
|
||||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||||
if (!replyResult) {
|
if (!replyResult) {
|
||||||
replyResult = await getReplyFromConfig(
|
replyResult = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body,
|
Body,
|
||||||
From,
|
From,
|
||||||
To,
|
To,
|
||||||
MessageSid,
|
MessageSid,
|
||||||
MediaPath: mediaPath,
|
MediaPath: mediaPath,
|
||||||
MediaUrl: mediaUrlInbound,
|
MediaUrl: mediaUrlInbound,
|
||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
if (replyResult && (replyResult.text || replyResult.mediaUrl)) {
|
||||||
try {
|
try {
|
||||||
let mediaUrl = replyResult.mediaUrl;
|
let mediaUrl = replyResult.mediaUrl;
|
||||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||||
const hosted = await ensureMediaHosted(mediaUrl);
|
const hosted = await ensureMediaHosted(mediaUrl);
|
||||||
mediaUrl = hosted.url;
|
mediaUrl = hosted.url;
|
||||||
}
|
}
|
||||||
await client.messages.create({
|
await client.messages.create({
|
||||||
from: To,
|
from: To,
|
||||||
to: From,
|
to: From,
|
||||||
body: replyResult.text ?? "",
|
body: replyResult.text ?? "",
|
||||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||||
});
|
});
|
||||||
if (verbose)
|
if (verbose)
|
||||||
runtime.log(
|
runtime.log(
|
||||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTwilioSendError(err, From ?? undefined, runtime);
|
logTwilioSendError(err, From ?? undefined, runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respond 200 OK to Twilio.
|
// Respond 200 OK to Twilio.
|
||||||
res.type("text/xml").send("<Response></Response>");
|
res.type("text/xml").send("<Response></Response>");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||||
res.status(404).send("warelay webhook: not found");
|
res.status(404).send("warelay webhook: not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server and resolve once listening; reject on bind error.
|
// Start server and resolve once listening; reject on bind error.
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = app.listen(port);
|
const server = app.listen(port);
|
||||||
|
|
||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||||
);
|
);
|
||||||
resolve(server);
|
resolve(server);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onError = (err: NodeJS.ErrnoException) => {
|
const onError = (err: NodeJS.ErrnoException) => {
|
||||||
cleanup();
|
cleanup();
|
||||||
reject(err);
|
reject(err);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
server.off("listening", onListening);
|
server.off("listening", onListening);
|
||||||
server.off("error", onError);
|
server.off("error", onError);
|
||||||
};
|
};
|
||||||
|
|
||||||
server.once("listening", onListening);
|
server.once("listening", onListening);
|
||||||
server.once("error", onError);
|
server.once("error", onError);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTwilioBasicAuth(env: EnvConfig) {
|
function buildTwilioBasicAuth(env: EnvConfig) {
|
||||||
if ("authToken" in env.auth) {
|
if ("authToken" in env.auth) {
|
||||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
||||||
"base64",
|
"base64",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
||||||
"base64",
|
"base64",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+43
-43
@@ -3,67 +3,67 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
assertProvider,
|
assertProvider,
|
||||||
ensureDir,
|
ensureDir,
|
||||||
normalizeE164,
|
normalizeE164,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
sleep,
|
sleep,
|
||||||
toWhatsappJid,
|
toWhatsappJid,
|
||||||
withWhatsAppPrefix,
|
withWhatsAppPrefix,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
|
|
||||||
describe("normalizePath", () => {
|
describe("normalizePath", () => {
|
||||||
it("adds leading slash when missing", () => {
|
it("adds leading slash when missing", () => {
|
||||||
expect(normalizePath("foo")).toBe("/foo");
|
expect(normalizePath("foo")).toBe("/foo");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps existing slash", () => {
|
it("keeps existing slash", () => {
|
||||||
expect(normalizePath("/bar")).toBe("/bar");
|
expect(normalizePath("/bar")).toBe("/bar");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("withWhatsAppPrefix", () => {
|
describe("withWhatsAppPrefix", () => {
|
||||||
it("adds whatsapp prefix", () => {
|
it("adds whatsapp prefix", () => {
|
||||||
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
|
expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves prefixed intact", () => {
|
it("leaves prefixed intact", () => {
|
||||||
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
|
expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ensureDir", () => {
|
describe("ensureDir", () => {
|
||||||
it("creates nested directory", async () => {
|
it("creates nested directory", async () => {
|
||||||
const tmp = await fs.promises.mkdtemp(
|
const tmp = await fs.promises.mkdtemp(
|
||||||
path.join(os.tmpdir(), "warelay-test-"),
|
path.join(os.tmpdir(), "warelay-test-"),
|
||||||
);
|
);
|
||||||
const target = path.join(tmp, "nested", "dir");
|
const target = path.join(tmp, "nested", "dir");
|
||||||
await ensureDir(target);
|
await ensureDir(target);
|
||||||
expect(fs.existsSync(target)).toBe(true);
|
expect(fs.existsSync(target)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sleep", () => {
|
describe("sleep", () => {
|
||||||
it("resolves after delay using fake timers", async () => {
|
it("resolves after delay using fake timers", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const promise = sleep(1000);
|
const promise = sleep(1000);
|
||||||
vi.advanceTimersByTime(1000);
|
vi.advanceTimersByTime(1000);
|
||||||
await expect(promise).resolves.toBeUndefined();
|
await expect(promise).resolves.toBeUndefined();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assertProvider", () => {
|
describe("assertProvider", () => {
|
||||||
it("throws for invalid provider", () => {
|
it("throws for invalid provider", () => {
|
||||||
expect(() => assertProvider("bad" as string)).toThrow();
|
expect(() => assertProvider("bad" as string)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeE164 & toWhatsappJid", () => {
|
describe("normalizeE164 & toWhatsappJid", () => {
|
||||||
it("strips formatting and prefixes", () => {
|
it("strips formatting and prefixes", () => {
|
||||||
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567");
|
||||||
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
|
expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe(
|
||||||
"5551234567@s.whatsapp.net",
|
"5551234567@s.whatsapp.net",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+20
-20
@@ -2,49 +2,49 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
export async function ensureDir(dir: string) {
|
export async function ensureDir(dir: string) {
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Provider = "twilio" | "web";
|
export type Provider = "twilio" | "web";
|
||||||
|
|
||||||
export function assertProvider(input: string): asserts input is Provider {
|
export function assertProvider(input: string): asserts input is Provider {
|
||||||
if (input !== "twilio" && input !== "web") {
|
if (input !== "twilio" && input !== "web") {
|
||||||
throw new Error("Provider must be 'twilio' or 'web'");
|
throw new Error("Provider must be 'twilio' or 'web'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePath(p: string): string {
|
export function normalizePath(p: string): string {
|
||||||
if (!p.startsWith("/")) return `/${p}`;
|
if (!p.startsWith("/")) return `/${p}`;
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withWhatsAppPrefix(number: string): string {
|
export function withWhatsAppPrefix(number: string): string {
|
||||||
return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`;
|
return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeE164(number: string): string {
|
export function normalizeE164(number: string): string {
|
||||||
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
|
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
|
||||||
const digits = withoutPrefix.replace(/[^\d+]/g, "");
|
const digits = withoutPrefix.replace(/[^\d+]/g, "");
|
||||||
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
|
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
|
||||||
return `+${digits}`;
|
return `+${digits}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toWhatsappJid(number: string): string {
|
export function toWhatsappJid(number: string): string {
|
||||||
const e164 = normalizeE164(number);
|
const e164 = normalizeE164(number);
|
||||||
const digits = e164.replace(/\D/g, "");
|
const digits = e164.replace(/\D/g, "");
|
||||||
return `${digits}@s.whatsapp.net`;
|
return `${digits}@s.whatsapp.net`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jidToE164(jid: string): string | null {
|
export function jidToE164(jid: string): string | null {
|
||||||
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
|
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
|
||||||
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
|
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const digits = match[1];
|
const digits = match[1];
|
||||||
return `+${digits}`;
|
return `+${digits}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms: number) {
|
export function sleep(ms: number) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONFIG_DIR = `${os.homedir()}/.warelay`;
|
export const CONFIG_DIR = `${os.homedir()}/.warelay`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const pkg = require("../package.json") as { version?: string };
|
||||||
|
|
||||||
|
// Single source of truth for the current warelay version (reads from package.json).
|
||||||
|
export const VERSION = pkg.version ?? "0.0.0";
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
|
import { monitorWebProvider } from "./auto-reply.js";
|
||||||
|
import {
|
||||||
|
resetBaileysMocks,
|
||||||
|
resetLoadConfigMock,
|
||||||
|
setLoadConfigMock,
|
||||||
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
|
describe("web auto-reply", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetBaileysMocks();
|
||||||
|
resetLoadConfigMock();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetLogger();
|
||||||
|
setLoggerOverride(null);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnects after a connection close", async () => {
|
||||||
|
const closeResolvers: Array<() => void> = [];
|
||||||
|
const sleep = vi.fn(async () => {});
|
||||||
|
const listenerFactory = vi.fn(async () => {
|
||||||
|
let _resolve!: () => void;
|
||||||
|
const onClose = new Promise<void>((res) => {
|
||||||
|
_resolve = res;
|
||||||
|
closeResolvers.push(res);
|
||||||
|
});
|
||||||
|
return { close: vi.fn(), onClose };
|
||||||
|
});
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new AbortController();
|
||||||
|
const run = monitorWebProvider(
|
||||||
|
false,
|
||||||
|
listenerFactory,
|
||||||
|
true,
|
||||||
|
async () => ({ text: "ok" }),
|
||||||
|
runtime as never,
|
||||||
|
controller.signal,
|
||||||
|
{
|
||||||
|
heartbeatSeconds: 1,
|
||||||
|
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
|
||||||
|
sleep,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
closeResolvers[0]?.();
|
||||||
|
const waitForSecondCall = async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
while (
|
||||||
|
listenerFactory.mock.calls.length < 2 &&
|
||||||
|
Date.now() - started < 200
|
||||||
|
) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await waitForSecondCall();
|
||||||
|
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runtime.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Retry 1"),
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
closeResolvers[1]?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await run;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after hitting max reconnect attempts", async () => {
|
||||||
|
const closeResolvers: Array<() => void> = [];
|
||||||
|
const sleep = vi.fn(async () => {});
|
||||||
|
const listenerFactory = vi.fn(async () => {
|
||||||
|
const onClose = new Promise<void>((res) => closeResolvers.push(res));
|
||||||
|
return { close: vi.fn(), onClose };
|
||||||
|
});
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = monitorWebProvider(
|
||||||
|
false,
|
||||||
|
listenerFactory,
|
||||||
|
true,
|
||||||
|
async () => ({ text: "ok" }),
|
||||||
|
runtime as never,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
heartbeatSeconds: 1,
|
||||||
|
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 2, factor: 1.1 },
|
||||||
|
sleep,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
closeResolvers.shift()?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
closeResolvers.shift()?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
await run;
|
||||||
|
|
||||||
|
expect(runtime.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Reached max retries"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to text when media send fails", async () => {
|
||||||
|
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
|
||||||
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sendComposing = vi.fn();
|
||||||
|
const resolver = vi.fn().mockResolvedValue({
|
||||||
|
text: "hi",
|
||||||
|
mediaUrl: "https://example.com/img.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedOnMessage:
|
||||||
|
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const listenerFactory = async (opts: {
|
||||||
|
onMessage: (
|
||||||
|
msg: import("./inbound.js").WebInboundMessage,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
capturedOnMessage = opts.onMessage;
|
||||||
|
return { close: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const smallPng = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
channels: 3,
|
||||||
|
background: { r: 0, g: 255, b: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: true,
|
||||||
|
arrayBuffer: async () =>
|
||||||
|
smallPng.buffer.slice(
|
||||||
|
smallPng.byteOffset,
|
||||||
|
smallPng.byteOffset + smallPng.byteLength,
|
||||||
|
),
|
||||||
|
headers: { get: () => "image/png" },
|
||||||
|
status: 200,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "hello",
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
id: "msg1",
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reply).toHaveBeenCalledWith("hi");
|
||||||
|
fetchMock.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compresses media over 5MB and still sends it", async () => {
|
||||||
|
const sendMedia = vi.fn();
|
||||||
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sendComposing = vi.fn();
|
||||||
|
const resolver = vi.fn().mockResolvedValue({
|
||||||
|
text: "hi",
|
||||||
|
mediaUrl: "https://example.com/big.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedOnMessage:
|
||||||
|
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const listenerFactory = async (opts: {
|
||||||
|
onMessage: (
|
||||||
|
msg: import("./inbound.js").WebInboundMessage,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
capturedOnMessage = opts.onMessage;
|
||||||
|
return { close: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const bigPng = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 3200,
|
||||||
|
height: 3200,
|
||||||
|
channels: 3,
|
||||||
|
background: { r: 255, g: 0, b: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 0 })
|
||||||
|
.toBuffer();
|
||||||
|
expect(bigPng.length).toBeGreaterThan(5 * 1024 * 1024);
|
||||||
|
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: true,
|
||||||
|
arrayBuffer: async () =>
|
||||||
|
bigPng.buffer.slice(
|
||||||
|
bigPng.byteOffset,
|
||||||
|
bigPng.byteOffset + bigPng.byteLength,
|
||||||
|
),
|
||||||
|
headers: { get: () => "image/png" },
|
||||||
|
status: 200,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "hello",
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
id: "msg1",
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = sendMedia.mock.calls[0][0] as {
|
||||||
|
image: Buffer;
|
||||||
|
caption?: string;
|
||||||
|
mimetype?: string;
|
||||||
|
};
|
||||||
|
expect(payload.image.length).toBeLessThanOrEqual(5 * 1024 * 1024);
|
||||||
|
expect(payload.mimetype).toBe("image/jpeg");
|
||||||
|
// Should not fall back to separate text reply because caption is used.
|
||||||
|
expect(reply).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchMock.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"compresses common formats to jpeg under the cap",
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
async () => {
|
||||||
|
const formats = [
|
||||||
|
{
|
||||||
|
name: "png",
|
||||||
|
mime: "image/png",
|
||||||
|
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||||
|
sharp(buf, {
|
||||||
|
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 0 })
|
||||||
|
.toBuffer(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jpeg",
|
||||||
|
mime: "image/jpeg",
|
||||||
|
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||||
|
sharp(buf, {
|
||||||
|
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 100, chromaSubsampling: "4:4:4" })
|
||||||
|
.toBuffer(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webp",
|
||||||
|
mime: "image/webp",
|
||||||
|
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||||
|
sharp(buf, {
|
||||||
|
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||||
|
})
|
||||||
|
.webp({ quality: 100 })
|
||||||
|
.toBuffer(),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const fmt of formats) {
|
||||||
|
// Force a small cap to ensure compression is exercised for every format.
|
||||||
|
setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } }));
|
||||||
|
const sendMedia = vi.fn();
|
||||||
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sendComposing = vi.fn();
|
||||||
|
const resolver = vi.fn().mockResolvedValue({
|
||||||
|
text: "hi",
|
||||||
|
mediaUrl: `https://example.com/big.${fmt.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedOnMessage:
|
||||||
|
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const listenerFactory = async (opts: {
|
||||||
|
onMessage: (
|
||||||
|
msg: import("./inbound.js").WebInboundMessage,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
capturedOnMessage = opts.onMessage;
|
||||||
|
return { close: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const width = 1200;
|
||||||
|
const height = 1200;
|
||||||
|
const raw = crypto.randomBytes(width * height * 3);
|
||||||
|
const big = await fmt.make(raw, { width, height });
|
||||||
|
expect(big.length).toBeGreaterThan(1 * 1024 * 1024);
|
||||||
|
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: true,
|
||||||
|
arrayBuffer: async () =>
|
||||||
|
big.buffer.slice(big.byteOffset, big.byteOffset + big.byteLength),
|
||||||
|
headers: { get: () => fmt.mime },
|
||||||
|
status: 200,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "hello",
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
id: `msg-${fmt.name}`,
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = sendMedia.mock.calls[0][0] as {
|
||||||
|
image: Buffer;
|
||||||
|
mimetype?: string;
|
||||||
|
};
|
||||||
|
expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024);
|
||||||
|
expect(payload.mimetype).toBe("image/jpeg");
|
||||||
|
expect(reply).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchMock.mockRestore();
|
||||||
|
resetLoadConfigMock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("honors mediaMaxMb from config", async () => {
|
||||||
|
setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } }));
|
||||||
|
const sendMedia = vi.fn();
|
||||||
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sendComposing = vi.fn();
|
||||||
|
const resolver = vi.fn().mockResolvedValue({
|
||||||
|
text: "hi",
|
||||||
|
mediaUrl: "https://example.com/big.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedOnMessage:
|
||||||
|
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const listenerFactory = async (opts: {
|
||||||
|
onMessage: (
|
||||||
|
msg: import("./inbound.js").WebInboundMessage,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
capturedOnMessage = opts.onMessage;
|
||||||
|
return { close: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const bigPng = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 2600,
|
||||||
|
height: 2600,
|
||||||
|
channels: 3,
|
||||||
|
background: { r: 0, g: 0, b: 255 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 0 })
|
||||||
|
.toBuffer();
|
||||||
|
expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024);
|
||||||
|
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: true,
|
||||||
|
arrayBuffer: async () =>
|
||||||
|
bigPng.buffer.slice(
|
||||||
|
bigPng.byteOffset,
|
||||||
|
bigPng.byteOffset + bigPng.byteLength,
|
||||||
|
),
|
||||||
|
headers: { get: () => "image/png" },
|
||||||
|
status: 200,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "hello",
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
id: "msg1",
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = sendMedia.mock.calls[0][0] as {
|
||||||
|
image: Buffer;
|
||||||
|
caption?: string;
|
||||||
|
mimetype?: string;
|
||||||
|
};
|
||||||
|
expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024);
|
||||||
|
expect(payload.mimetype).toBe("image/jpeg");
|
||||||
|
expect(reply).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchMock.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to text when media is unsupported", async () => {
|
||||||
|
const sendMedia = vi.fn();
|
||||||
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sendComposing = vi.fn();
|
||||||
|
const resolver = vi.fn().mockResolvedValue({
|
||||||
|
text: "hi",
|
||||||
|
mediaUrl: "https://example.com/file.pdf",
|
||||||
|
});
|
||||||
|
|
||||||
|
let capturedOnMessage:
|
||||||
|
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const listenerFactory = async (opts: {
|
||||||
|
onMessage: (
|
||||||
|
msg: import("./inbound.js").WebInboundMessage,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
capturedOnMessage = opts.onMessage;
|
||||||
|
return { close: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: true,
|
||||||
|
arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer,
|
||||||
|
headers: { get: () => "application/pdf" },
|
||||||
|
status: 200,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "hello",
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
id: "msg-pdf",
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = sendMedia.mock.calls[0][0] as {
|
||||||
|
document?: Buffer;
|
||||||
|
caption?: string;
|
||||||
|
fileName?: string;
|
||||||
|
};
|
||||||
|
expect(payload.document).toBeInstanceOf(Buffer);
|
||||||
|
expect(payload.fileName).toBe("file.pdf");
|
||||||
|
expect(payload.caption).toBe("hi");
|
||||||
|
expect(reply).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchMock.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits heartbeat logs with connection metadata", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const logPath = `/tmp/warelay-heartbeat-${crypto.randomUUID()}.log`;
|
||||||
|
setLoggerOverride({ level: "trace", file: logPath });
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const listenerFactory = vi.fn(async () => {
|
||||||
|
const onClose = new Promise<void>(() => {
|
||||||
|
// never resolves; abort will short-circuit
|
||||||
|
});
|
||||||
|
return { close: vi.fn(), onClose };
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = monitorWebProvider(
|
||||||
|
false,
|
||||||
|
listenerFactory,
|
||||||
|
true,
|
||||||
|
async () => ({ text: "ok" }),
|
||||||
|
runtime as never,
|
||||||
|
controller.signal,
|
||||||
|
{
|
||||||
|
heartbeatSeconds: 1,
|
||||||
|
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 1, factor: 1.1 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
controller.abort();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await run.catch(() => {});
|
||||||
|
|
||||||
|
const content = await fs.readFile(logPath, "utf-8");
|
||||||
|
expect(content).toContain('"module":"web-heartbeat"');
|
||||||
|
expect(content).toMatch(/connectionId/);
|
||||||
|
expect(content).toMatch(/messagesHandled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs outbound replies to file", async () => {
|
||||||
|
const logPath = `/tmp/warelay-log-test-${crypto.randomUUID()}.log`;
|
||||||
|
setLoggerOverride({ level: "trace", file: logPath });
|
||||||
|
|
||||||
|
let capturedOnMessage:
|
||||||
|
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const listenerFactory = async (opts: {
|
||||||
|
onMessage: (
|
||||||
|
msg: import("./inbound.js").WebInboundMessage,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
capturedOnMessage = opts.onMessage;
|
||||||
|
return { close: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "hello",
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
id: "msg1",
|
||||||
|
sendComposing: vi.fn(),
|
||||||
|
reply: vi.fn(),
|
||||||
|
sendMedia: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = await fs.readFile(logPath, "utf-8");
|
||||||
|
expect(content).toContain('"module":"web-auto-reply"');
|
||||||
|
expect(content).toContain('"text":"auto"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import { waitForever } from "../cli/wait.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { danger, isVerbose, logVerbose, success } from "../globals.js";
|
||||||
|
import { logInfo } from "../logger.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { monitorWebInbox } from "./inbound.js";
|
||||||
|
import { loadWebMedia } from "./media.js";
|
||||||
|
import {
|
||||||
|
computeBackoff,
|
||||||
|
newConnectionId,
|
||||||
|
type ReconnectPolicy,
|
||||||
|
resolveHeartbeatSeconds,
|
||||||
|
resolveReconnectPolicy,
|
||||||
|
sleepWithAbort,
|
||||||
|
} from "./reconnect.js";
|
||||||
|
import { getWebAuthAgeMs } from "./session.js";
|
||||||
|
|
||||||
|
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type WebMonitorTuning = {
|
||||||
|
reconnect?: Partial<ReconnectPolicy>;
|
||||||
|
heartbeatSeconds?: number;
|
||||||
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number) =>
|
||||||
|
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||||
|
|
||||||
|
export async function monitorWebProvider(
|
||||||
|
verbose: boolean,
|
||||||
|
listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox,
|
||||||
|
keepAlive = true,
|
||||||
|
replyResolver: typeof getReplyFromConfig | undefined = getReplyFromConfig,
|
||||||
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
tuning: WebMonitorTuning = {},
|
||||||
|
) {
|
||||||
|
const runId = newConnectionId();
|
||||||
|
const replyLogger = getChildLogger({ module: "web-auto-reply", runId });
|
||||||
|
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
|
||||||
|
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb;
|
||||||
|
const maxMediaBytes =
|
||||||
|
typeof configuredMaxMb === "number" && configuredMaxMb > 0
|
||||||
|
? configuredMaxMb * 1024 * 1024
|
||||||
|
: DEFAULT_WEB_MEDIA_BYTES;
|
||||||
|
const heartbeatSeconds = resolveHeartbeatSeconds(
|
||||||
|
cfg,
|
||||||
|
tuning.heartbeatSeconds,
|
||||||
|
);
|
||||||
|
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||||
|
const sleep =
|
||||||
|
tuning.sleep ??
|
||||||
|
((ms: number, signal?: AbortSignal) =>
|
||||||
|
sleepWithAbort(ms, signal ?? abortSignal));
|
||||||
|
const stopRequested = () => abortSignal?.aborted === true;
|
||||||
|
const abortPromise =
|
||||||
|
abortSignal &&
|
||||||
|
new Promise<"aborted">((resolve) =>
|
||||||
|
abortSignal.addEventListener("abort", () => resolve("aborted"), {
|
||||||
|
once: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let sigintStop = false;
|
||||||
|
const handleSigint = () => {
|
||||||
|
sigintStop = true;
|
||||||
|
};
|
||||||
|
process.once("SIGINT", handleSigint);
|
||||||
|
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (stopRequested()) break;
|
||||||
|
|
||||||
|
const connectionId = newConnectionId();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let heartbeat: NodeJS.Timeout | null = null;
|
||||||
|
let lastMessageAt: number | null = null;
|
||||||
|
let handledMessages = 0;
|
||||||
|
|
||||||
|
const listener = await (listenerFactory ?? monitorWebInbox)({
|
||||||
|
verbose,
|
||||||
|
onMessage: async (msg) => {
|
||||||
|
handledMessages += 1;
|
||||||
|
lastMessageAt = Date.now();
|
||||||
|
const ts = msg.timestamp
|
||||||
|
? new Date(msg.timestamp).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
const correlationId = msg.id ?? newConnectionId();
|
||||||
|
replyLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
correlationId,
|
||||||
|
from: msg.from,
|
||||||
|
to: msg.to,
|
||||||
|
body: msg.body,
|
||||||
|
mediaType: msg.mediaType ?? null,
|
||||||
|
mediaPath: msg.mediaPath ?? null,
|
||||||
|
},
|
||||||
|
"inbound web message",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
||||||
|
|
||||||
|
const replyStarted = Date.now();
|
||||||
|
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||||
|
{
|
||||||
|
Body: msg.body,
|
||||||
|
From: msg.from,
|
||||||
|
To: msg.to,
|
||||||
|
MessageSid: msg.id,
|
||||||
|
MediaPath: msg.mediaPath,
|
||||||
|
MediaUrl: msg.mediaUrl,
|
||||||
|
MediaType: msg.mediaType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onReplyStart: msg.sendComposing,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!replyResult ||
|
||||||
|
(!replyResult.text &&
|
||||||
|
!replyResult.mediaUrl &&
|
||||||
|
!replyResult.mediaUrls?.length)
|
||||||
|
) {
|
||||||
|
logVerbose(
|
||||||
|
"Skipping auto-reply: no text/media returned from resolver",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mediaList = replyResult.mediaUrls?.length
|
||||||
|
? replyResult.mediaUrls
|
||||||
|
: replyResult.mediaUrl
|
||||||
|
? [replyResult.mediaUrl]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (mediaList.length > 0) {
|
||||||
|
logVerbose(
|
||||||
|
`Web auto-reply media detected: ${mediaList.filter(Boolean).join(", ")}`,
|
||||||
|
);
|
||||||
|
for (const [index, mediaUrl] of mediaList.entries()) {
|
||||||
|
try {
|
||||||
|
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
|
||||||
|
if (isVerbose()) {
|
||||||
|
logVerbose(
|
||||||
|
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
|
||||||
|
);
|
||||||
|
logVerbose(
|
||||||
|
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const caption =
|
||||||
|
index === 0 ? replyResult.text || undefined : undefined;
|
||||||
|
if (media.kind === "image") {
|
||||||
|
await msg.sendMedia({
|
||||||
|
image: media.buffer,
|
||||||
|
caption,
|
||||||
|
mimetype: media.contentType,
|
||||||
|
});
|
||||||
|
} else if (media.kind === "audio") {
|
||||||
|
await msg.sendMedia({
|
||||||
|
audio: media.buffer,
|
||||||
|
ptt: true,
|
||||||
|
mimetype: media.contentType,
|
||||||
|
caption,
|
||||||
|
});
|
||||||
|
} else if (media.kind === "video") {
|
||||||
|
await msg.sendMedia({
|
||||||
|
video: media.buffer,
|
||||||
|
caption,
|
||||||
|
mimetype: media.contentType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const fileName = mediaUrl.split("/").pop() ?? "file";
|
||||||
|
const mimetype =
|
||||||
|
media.contentType ?? "application/octet-stream";
|
||||||
|
await msg.sendMedia({
|
||||||
|
document: media.buffer,
|
||||||
|
fileName,
|
||||||
|
caption,
|
||||||
|
mimetype,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logInfo(
|
||||||
|
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
replyLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
correlationId,
|
||||||
|
to: msg.from,
|
||||||
|
from: msg.to,
|
||||||
|
text: index === 0 ? (replyResult.text ?? null) : null,
|
||||||
|
mediaUrl,
|
||||||
|
mediaSizeBytes: media.buffer.length,
|
||||||
|
mediaKind: media.kind,
|
||||||
|
durationMs: Date.now() - replyStarted,
|
||||||
|
},
|
||||||
|
"auto-reply sent (media)",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
danger(
|
||||||
|
`Failed sending web media to ${msg.from}: ${String(err)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (index === 0 && replyResult.text) {
|
||||||
|
console.log(
|
||||||
|
`⚠️ Media skipped; sent text-only to ${msg.from}`,
|
||||||
|
);
|
||||||
|
await msg.reply(replyResult.text || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (replyResult.text) {
|
||||||
|
await msg.reply(replyResult.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - replyStarted;
|
||||||
|
const hasMedia = mediaList.length > 0;
|
||||||
|
if (isVerbose()) {
|
||||||
|
console.log(
|
||||||
|
success(
|
||||||
|
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${hasMedia ? ", media" : ""}, ${formatDuration(durationMs)})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
success(
|
||||||
|
`↩️ ${replyResult.text ?? "<media>"}${hasMedia ? " (media)" : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
replyLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
correlationId,
|
||||||
|
to: msg.from,
|
||||||
|
from: msg.to,
|
||||||
|
text: replyResult.text ?? null,
|
||||||
|
mediaUrl: mediaList[0] ?? null,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
"auto-reply sent",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
danger(
|
||||||
|
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeListener = async () => {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
try {
|
||||||
|
await listener.close();
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Socket close failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (keepAlive) {
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
const authAgeMs = getWebAuthAgeMs();
|
||||||
|
heartbeatLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
reconnectAttempts,
|
||||||
|
messagesHandled: handledMessages,
|
||||||
|
lastMessageAt,
|
||||||
|
authAgeMs,
|
||||||
|
uptimeMs: Date.now() - startedAt,
|
||||||
|
},
|
||||||
|
"web relay heartbeat",
|
||||||
|
);
|
||||||
|
}, heartbeatSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(
|
||||||
|
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!keepAlive) {
|
||||||
|
await closeListener();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = await Promise.race([
|
||||||
|
listener.onClose ?? waitForever(),
|
||||||
|
abortPromise ?? waitForever(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const uptimeMs = Date.now() - startedAt;
|
||||||
|
if (uptimeMs > heartbeatSeconds * 1000) {
|
||||||
|
reconnectAttempts = 0; // Healthy stretch; reset the backoff.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopRequested() || sigintStop || reason === "aborted") {
|
||||||
|
await closeListener();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status =
|
||||||
|
(typeof reason === "object" && reason && "status" in reason
|
||||||
|
? (reason as { status?: number }).status
|
||||||
|
: undefined) ?? "unknown";
|
||||||
|
const loggedOut =
|
||||||
|
typeof reason === "object" &&
|
||||||
|
reason &&
|
||||||
|
"isLoggedOut" in reason &&
|
||||||
|
(reason as { isLoggedOut?: boolean }).isLoggedOut;
|
||||||
|
|
||||||
|
reconnectLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
status,
|
||||||
|
loggedOut,
|
||||||
|
reconnectAttempts,
|
||||||
|
},
|
||||||
|
"web reconnect: connection closed",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loggedOut) {
|
||||||
|
runtime.error(
|
||||||
|
danger(
|
||||||
|
"WhatsApp session logged out. Run `warelay login --provider web` to relink.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await closeListener();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempts += 1;
|
||||||
|
if (
|
||||||
|
reconnectPolicy.maxAttempts > 0 &&
|
||||||
|
reconnectAttempts >= reconnectPolicy.maxAttempts
|
||||||
|
) {
|
||||||
|
reconnectLogger.warn(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
status,
|
||||||
|
reconnectAttempts,
|
||||||
|
maxAttempts: reconnectPolicy.maxAttempts,
|
||||||
|
},
|
||||||
|
"web reconnect: max attempts reached",
|
||||||
|
);
|
||||||
|
runtime.error(
|
||||||
|
danger(
|
||||||
|
`WhatsApp Web connection closed (status ${status}). Reached max retries (${reconnectPolicy.maxAttempts}); exiting so you can relink.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await closeListener();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||||
|
reconnectLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
status,
|
||||||
|
reconnectAttempts,
|
||||||
|
maxAttempts: reconnectPolicy.maxAttempts || "unlimited",
|
||||||
|
delayMs: delay,
|
||||||
|
},
|
||||||
|
"web reconnect: scheduling retry",
|
||||||
|
);
|
||||||
|
runtime.error(
|
||||||
|
danger(
|
||||||
|
`WhatsApp Web connection closed (status ${status}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDuration(delay)}…`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await closeListener();
|
||||||
|
try {
|
||||||
|
await sleep(delay, abortSignal);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.removeListener("SIGINT", handleSigint);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_WEB_MEDIA_BYTES };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { extractMediaPlaceholder, extractText } from "./inbound.js";
|
||||||
|
|
||||||
|
describe("web inbound helpers", () => {
|
||||||
|
it("prefers the main conversation body", () => {
|
||||||
|
const body = extractText({
|
||||||
|
conversation: " hello ",
|
||||||
|
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
|
||||||
|
expect(body).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to captions when conversation text is missing", () => {
|
||||||
|
const body = extractText({
|
||||||
|
imageMessage: { caption: " caption " },
|
||||||
|
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
|
||||||
|
expect(body).toBe("caption");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns placeholders for media-only payloads", () => {
|
||||||
|
expect(
|
||||||
|
extractMediaPlaceholder({
|
||||||
|
imageMessage: {},
|
||||||
|
} as unknown as import("@whiskeysockets/baileys").proto.IMessage),
|
||||||
|
).toBe("<media:image>");
|
||||||
|
expect(
|
||||||
|
extractMediaPlaceholder({
|
||||||
|
audioMessage: {},
|
||||||
|
} as unknown as import("@whiskeysockets/baileys").proto.IMessage),
|
||||||
|
).toBe("<media:audio>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import type {
|
||||||
|
AnyMessageContent,
|
||||||
|
proto,
|
||||||
|
WAMessage,
|
||||||
|
} from "@whiskeysockets/baileys";
|
||||||
|
import {
|
||||||
|
DisconnectReason,
|
||||||
|
downloadMediaMessage,
|
||||||
|
} from "@whiskeysockets/baileys";
|
||||||
|
|
||||||
|
import { isVerbose, logVerbose } from "../globals.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
|
import { jidToE164 } from "../utils.js";
|
||||||
|
import {
|
||||||
|
createWaSocket,
|
||||||
|
getStatusCode,
|
||||||
|
waitForWaConnection,
|
||||||
|
} from "./session.js";
|
||||||
|
|
||||||
|
export type WebListenerCloseReason = {
|
||||||
|
status?: number;
|
||||||
|
isLoggedOut: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebInboundMessage = {
|
||||||
|
id?: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
body: string;
|
||||||
|
pushName?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
sendComposing: () => Promise<void>;
|
||||||
|
reply: (text: string) => Promise<void>;
|
||||||
|
sendMedia: (payload: AnyMessageContent) => Promise<void>;
|
||||||
|
mediaPath?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function monitorWebInbox(options: {
|
||||||
|
verbose: boolean;
|
||||||
|
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const inboundLogger = getChildLogger({ module: "web-inbound" });
|
||||||
|
const sock = await createWaSocket(false, options.verbose);
|
||||||
|
await waitForWaConnection(sock);
|
||||||
|
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
|
||||||
|
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
|
||||||
|
onCloseResolve = resolve;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Advertise that the relay is online right after connecting.
|
||||||
|
await sock.sendPresenceUpdate("available");
|
||||||
|
if (isVerbose()) logVerbose("Sent global 'available' presence on connect");
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(
|
||||||
|
`Failed to send 'available' presence on connect: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const selfJid = sock.user?.id;
|
||||||
|
const selfE164 = selfJid ? jidToE164(selfJid) : null;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
sock.ev.on("messages.upsert", async (upsert) => {
|
||||||
|
if (upsert.type !== "notify") return;
|
||||||
|
for (const msg of upsert.messages) {
|
||||||
|
const id = msg.key?.id ?? undefined;
|
||||||
|
// De-dupe on message id; Baileys can emit retries.
|
||||||
|
if (id && seen.has(id)) continue;
|
||||||
|
if (id) seen.add(id);
|
||||||
|
if (msg.key?.fromMe) continue;
|
||||||
|
const remoteJid = msg.key?.remoteJid;
|
||||||
|
if (!remoteJid) continue;
|
||||||
|
// Ignore status/broadcast traffic; we only care about direct chats.
|
||||||
|
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
||||||
|
continue;
|
||||||
|
if (id) {
|
||||||
|
const participant = msg.key?.participant;
|
||||||
|
try {
|
||||||
|
await sock.readMessages([
|
||||||
|
{ remoteJid, id, participant, fromMe: false },
|
||||||
|
]);
|
||||||
|
if (isVerbose()) {
|
||||||
|
const suffix = participant ? ` (participant ${participant})` : "";
|
||||||
|
logVerbose(
|
||||||
|
`Marked message ${id} as read for ${remoteJid}${suffix}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const from = jidToE164(remoteJid);
|
||||||
|
if (!from) continue;
|
||||||
|
let body = extractText(msg.message ?? undefined);
|
||||||
|
if (!body) {
|
||||||
|
body = extractMediaPlaceholder(msg.message ?? undefined);
|
||||||
|
if (!body) continue;
|
||||||
|
}
|
||||||
|
let mediaPath: string | undefined;
|
||||||
|
let mediaType: string | undefined;
|
||||||
|
try {
|
||||||
|
const inboundMedia = await downloadInboundMedia(msg, sock);
|
||||||
|
if (inboundMedia) {
|
||||||
|
const saved = await saveMediaBuffer(
|
||||||
|
inboundMedia.buffer,
|
||||||
|
inboundMedia.mimetype,
|
||||||
|
);
|
||||||
|
mediaPath = saved.path;
|
||||||
|
mediaType = inboundMedia.mimetype;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Inbound media download failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
const chatJid = remoteJid;
|
||||||
|
const sendComposing = async () => {
|
||||||
|
try {
|
||||||
|
await sock.sendPresenceUpdate("composing", chatJid);
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Presence update failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const reply = async (text: string) => {
|
||||||
|
await sock.sendMessage(chatJid, { text });
|
||||||
|
};
|
||||||
|
const sendMedia = async (payload: AnyMessageContent) => {
|
||||||
|
await sock.sendMessage(chatJid, payload);
|
||||||
|
};
|
||||||
|
const timestamp = msg.messageTimestamp
|
||||||
|
? Number(msg.messageTimestamp) * 1000
|
||||||
|
: undefined;
|
||||||
|
inboundLogger.info(
|
||||||
|
{
|
||||||
|
from,
|
||||||
|
to: selfE164 ?? "me",
|
||||||
|
body,
|
||||||
|
mediaPath,
|
||||||
|
mediaType,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
|
"inbound message",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await options.onMessage({
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
to: selfE164 ?? "me",
|
||||||
|
body,
|
||||||
|
pushName: msg.pushName ?? undefined,
|
||||||
|
timestamp,
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
mediaPath,
|
||||||
|
mediaType,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed handling inbound web message:", String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sock.ev.on(
|
||||||
|
"connection.update",
|
||||||
|
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
|
||||||
|
if (update.connection === "close") {
|
||||||
|
const status = getStatusCode(update.lastDisconnect?.error);
|
||||||
|
onCloseResolve?.({
|
||||||
|
status,
|
||||||
|
isLoggedOut: status === DisconnectReason.loggedOut,
|
||||||
|
error: update.lastDisconnect?.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
try {
|
||||||
|
sock.ws?.close();
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Socket close failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClose,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractText(
|
||||||
|
message: proto.IMessage | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!message) return undefined;
|
||||||
|
if (typeof message.conversation === "string" && message.conversation.trim()) {
|
||||||
|
return message.conversation.trim();
|
||||||
|
}
|
||||||
|
const extended = message.extendedTextMessage?.text;
|
||||||
|
if (extended?.trim()) return extended.trim();
|
||||||
|
const caption =
|
||||||
|
message.imageMessage?.caption ?? message.videoMessage?.caption;
|
||||||
|
if (caption?.trim()) return caption.trim();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMediaPlaceholder(
|
||||||
|
message: proto.IMessage | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!message) return undefined;
|
||||||
|
if (message.imageMessage) return "<media:image>";
|
||||||
|
if (message.videoMessage) return "<media:video>";
|
||||||
|
if (message.audioMessage) return "<media:audio>";
|
||||||
|
if (message.documentMessage) return "<media:document>";
|
||||||
|
if (message.stickerMessage) return "<media:sticker>";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadInboundMedia(
|
||||||
|
msg: proto.IWebMessageInfo,
|
||||||
|
sock: Awaited<ReturnType<typeof createWaSocket>>,
|
||||||
|
): Promise<{ buffer: Buffer; mimetype?: string } | undefined> {
|
||||||
|
const message = msg.message;
|
||||||
|
if (!message) return undefined;
|
||||||
|
const mimetype =
|
||||||
|
message.imageMessage?.mimetype ??
|
||||||
|
message.videoMessage?.mimetype ??
|
||||||
|
message.documentMessage?.mimetype ??
|
||||||
|
message.audioMessage?.mimetype ??
|
||||||
|
message.stickerMessage?.mimetype ??
|
||||||
|
undefined;
|
||||||
|
if (
|
||||||
|
!message.imageMessage &&
|
||||||
|
!message.videoMessage &&
|
||||||
|
!message.documentMessage &&
|
||||||
|
!message.audioMessage &&
|
||||||
|
!message.stickerMessage
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buffer = (await downloadMediaMessage(
|
||||||
|
msg as WAMessage,
|
||||||
|
"buffer",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
reuploadRequest: sock.updateMediaMessage,
|
||||||
|
logger: sock.logger,
|
||||||
|
},
|
||||||
|
)) as Buffer;
|
||||||
|
return { buffer, mimetype };
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
import { DisconnectReason } from "@whiskeysockets/baileys";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const rmMock = vi.spyOn(fs, "rm");
|
||||||
|
|
||||||
|
vi.mock("./session.js", () => {
|
||||||
|
const sockA = { ws: { close: vi.fn() } };
|
||||||
|
const sockB = { ws: { close: vi.fn() } };
|
||||||
|
const createWaSocket = vi.fn(async () =>
|
||||||
|
createWaSocket.mock.calls.length === 0 ? sockA : sockB,
|
||||||
|
);
|
||||||
|
const waitForWaConnection = vi.fn();
|
||||||
|
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
|
||||||
|
return {
|
||||||
|
createWaSocket,
|
||||||
|
waitForWaConnection,
|
||||||
|
formatError,
|
||||||
|
WA_WEB_AUTH_DIR: "/tmp/wa-creds",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createWaSocket, waitForWaConnection, formatError } = await import(
|
||||||
|
"./session.js"
|
||||||
|
);
|
||||||
|
const { loginWeb } = await import("./login.js");
|
||||||
|
|
||||||
|
describe("loginWeb coverage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
rmMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts once when WhatsApp requests code 515", async () => {
|
||||||
|
waitForWaConnection
|
||||||
|
.mockRejectedValueOnce({ output: { statusCode: 515 } })
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const runtime = { log: vi.fn(), error: vi.fn() } as never;
|
||||||
|
await loginWeb(false, waitForWaConnection as never, runtime);
|
||||||
|
|
||||||
|
expect(createWaSocket).toHaveBeenCalledTimes(2);
|
||||||
|
const firstSock = await createWaSocket.mock.results[0].value;
|
||||||
|
expect(firstSock.ws.close).toHaveBeenCalled();
|
||||||
|
vi.runAllTimers();
|
||||||
|
const secondSock = await createWaSocket.mock.results[1].value;
|
||||||
|
expect(secondSock.ws.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears creds and throws when logged out", async () => {
|
||||||
|
waitForWaConnection.mockRejectedValueOnce({
|
||||||
|
output: { statusCode: DisconnectReason.loggedOut },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow(
|
||||||
|
/cache cleared/i,
|
||||||
|
);
|
||||||
|
expect(rmMock).toHaveBeenCalledWith("/tmp/wa-creds", {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats and rethrows generic errors", async () => {
|
||||||
|
waitForWaConnection.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow(
|
||||||
|
"formatted:Error: boom",
|
||||||
|
);
|
||||||
|
expect(formatError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
|
|
||||||
|
vi.mock("./session.js", () => {
|
||||||
|
const ev = new EventEmitter();
|
||||||
|
const sock = {
|
||||||
|
ev,
|
||||||
|
ws: { close: vi.fn() },
|
||||||
|
sendPresenceUpdate: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
createWaSocket: vi.fn().mockResolvedValue(sock),
|
||||||
|
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { loginWeb } from "./login.js";
|
||||||
|
import type { waitForWaConnection } from "./session.js";
|
||||||
|
|
||||||
|
const { createWaSocket } = await import("./session.js");
|
||||||
|
|
||||||
|
describe("web login", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetLogger();
|
||||||
|
setLoggerOverride(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loginWeb waits for connection and closes", async () => {
|
||||||
|
const sock = await createWaSocket();
|
||||||
|
const waiter: typeof waitForWaConnection = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
await loginWeb(false, waiter);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||||
|
expect(sock.ws.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user