Compare commits

...

53 Commits

Author SHA1 Message Date
Peter Steinberger 5bafe9483d chore: release 1.2.2
CI / build (push) Failing after 36s
2025-11-28 08:17:22 +01:00
Peter Steinberger 4e3663b4d4 chore: move heartbeat notes to unreleased 1.2.2 2025-11-28 08:14:51 +01:00
Peter Steinberger 12d7be7cad feat(heartbeat): allow manual message and dry-run for web/twilio 2025-11-28 08:14:07 +01:00
Peter Steinberger 84f2595349 docs: note changelog not needed for pure tests 2025-11-28 08:13:59 +01:00
Peter Steinberger c11abc1134 chore: release 1.2.1
CI / build (push) Failing after 34s
2025-11-28 08:11:07 +01:00
Peter Steinberger f63bdda628 docs: document mime-first media handling 2025-11-28 08:07:53 +01:00
Peter Steinberger 7d6a4f5204 fix(media): sniff mime and keep extensions 2025-11-28 08:07:53 +01:00
Peter Steinberger f871869c79 Fix broken link: claude-config.md -> clawd.md 2025-11-28 05:19:43 +00:00
Peter Steinberger 8ebe72951f docs: Add Twitter automation and music recognition examples
- Added Twitter automation patterns using Peekaboo + AppleScript
- Documented JS injection for reliable button clicks on Twitter's dynamic UI
- Added audd.io music recognition API example
- These are the techniques Clawd uses to reply to tweets autonomously
2025-11-27 21:00:28 +00:00
Peter Steinberger 8d4b31a301 Expand heartbeat capabilities in docs 2025-11-27 19:09:30 +01:00
Peter Steinberger 8912b3e035 Rename claude-config.md to clawd.md, update credits
- Renamed docs/claude-config.md → docs/clawd.md
- Credits now include Clawd (they/them) as co-author
2025-11-27 19:07:35 +01:00
Peter Steinberger f5d7057042 Add browser-tools CLI and example tweets to docs
- Added browser-tools to CLI tools table (lightweight DevTools CLI)
- Added browser-tools usage section for web scraping
- Added "See It In Action" section with 3 example tweets
- Links to agent-scripts repo
2025-11-27 18:59:01 +01:00
Peter Steinberger 6d7e620430 Release 1.2.0
CI / build (push) Failing after 35s
2025-11-27 18:52:26 +01:00
Peter Steinberger 0cc732dce3 Docs: refresh 1.2.0 changelog; fix webhook host import 2025-11-27 18:46:46 +01:00
Peter Steinberger 8acd82aa0d Add gowa WhatsApp MCP to power user add-ons 2025-11-27 18:45:05 +01:00
Peter Steinberger 7377c676fd Add WhatsApp screenshot to claude-config.md
Shows Clawd in action in the "Meet Clawd" section
2025-11-27 18:43:24 +01:00
Peter Steinberger 9b3c4db10d Heartbeat defaults and ws guard; format 2025-11-27 18:37:30 +01:00
Peter Steinberger 49ada54f6d Docs: add useful CLI tools section (spotify-player, TTS, etc.) 2025-11-27 18:33:38 +01:00
Peter Steinberger c43cdc5ac3 Docs: new Clawd session intro with personality and powers 2025-11-27 18:32:47 +01:00
Peter Steinberger e1bd9976b3 Docs: explain two-phone setup for dedicated AI number 2025-11-27 18:29:41 +01:00
Peter Steinberger a888564251 Docs: mention Claude Code reuses existing subscription 2025-11-27 18:28:51 +01:00
Peter Steinberger e2ccde6434 Fix: warelay lowercase 2025-11-27 18:27:09 +01:00
Peter Steinberger e88ff78816 Add Peekaboo and mcporter links to recommended tools 2025-11-27 18:26:40 +01:00
Peter Steinberger 5bc151fdca Redact phone number from example config 2025-11-27 18:24:12 +01:00
Peter Steinberger f0a5cdc6e4 Add warning disclaimer to claude-config.md 2025-11-27 18:23:56 +01:00
Peter Steinberger 85f53a4174 Fix WebSocket crash + heartbeat default 10min + docs refresh
- Wrap Baileys connection.update listeners in try-catch to prevent
  unhandled exceptions from crashing the relay process
- Add WebSocket-level error handlers in session.ts
- Add global unhandledRejection/uncaughtException handlers in index.ts
- Make listener.onClose error-safe with .catch() in auto-reply.ts
- Change default heartbeat from 30min to 10min
- Rewrite claude-config.md with personality, better explain personal
  assistant features, add recommended MCPs section
2025-11-27 18:21:14 +01:00
Peter Steinberger 549ad272fc Docs: link Clawd setup and current config 2025-11-27 18:17:17 +01:00
Peter Steinberger 537348d995 Update README.md 2025-11-27 18:14:54 +01:00
Peter Steinberger d4580d1a31 Fix CI: type gaps and hasMedia check 2025-11-27 18:14:20 +01:00
Peter Steinberger 93a103dde5 Tests: cover identity prefix gating 2025-11-27 04:40:03 +01:00
Peter Steinberger 9e6ad97cfb Claude prompt: only prepend on first turn 2025-11-27 03:53:13 +01:00
Peter Steinberger 8d995a8529 Heartbeat: add ultrathink marker 2025-11-27 03:15:51 +01:00
Peter Steinberger f869cd4b79 Heartbeat: shorten prompt to token 2025-11-27 02:48:23 +01:00
Peter Steinberger 26b087c1b4 Heartbeat: honor session override 2025-11-26 18:32:25 +01:00
Peter Steinberger 63bf4683c5 Heartbeat: allow session-id override (with test) 2025-11-26 18:28:02 +01:00
Peter Steinberger 73456a68d7 Fix heartbeat CLI import for recipients resolution 2025-11-26 18:22:28 +01:00
Peter Steinberger aa6637b47a Heartbeat: session-id override and safer fallback 2025-11-26 18:19:54 +01:00
Peter Steinberger 8f6e43fd66 Changelog: bump to 1.2.0 unreleased 2025-11-26 18:18:13 +01:00
Peter Steinberger ebce6ef263 Docs: show --all heartbeat example 2025-11-26 18:17:30 +01:00
Peter Steinberger c20a266a11 Heartbeat: harden targeting and support lid mapping 2025-11-26 18:15:57 +01:00
Marcus Neves b825f141f3 fix: add @lid format support and allowFrom wildcard handling
- Add support for WhatsApp Linked ID (@lid) format in jidToE164()
- Use existing lid-mapping-*_reverse.json files for LID resolution
- Fix allowFrom wildcard '*' to actually allow all senders
- Maintain backward compatibility with @s.whatsapp.net format

Fixes issues where:
- Messages from newer WhatsApp versions are silently dropped
- allowFrom: ['*'] configuration doesn't work as documented
2025-11-26 18:03:12 +01:00
Peter Steinberger 7e5b3958cc CLI: rename heartbeat tmux helper and log file path 2025-11-26 18:00:23 +01:00
Peter Steinberger deded848ee Heartbeat: add relay helper and fix CLI tests 2025-11-26 17:49:34 +01:00
Peter Steinberger 117161e6ff docs: document heartbeat idle override and tests 2025-11-26 17:31:56 +01:00
Peter Steinberger 98d52edcc9 test: cover heartbeat skip preserving session timestamp 2025-11-26 17:29:12 +01:00
Peter Steinberger 135d930c99 feat: add heartbeat idle override and preserve session freshness 2025-11-26 17:26:17 +01:00
Peter Steinberger e6c78df975 chore: add verbose heartbeat session logging 2025-11-26 17:21:59 +01:00
Peter Steinberger 3749797434 chore: log heartbeat session snapshot 2025-11-26 17:20:48 +01:00
Peter Steinberger 507ed25289 chore: log heartbeat fallback and add test 2025-11-26 17:12:28 +01:00
Peter Steinberger 0d5e5f8dee fix: heartbeat falls back to last session contact 2025-11-26 17:08:43 +01:00
Peter Steinberger 3998933b30 docs: document heartbeat triggers 2025-11-26 17:05:09 +01:00
Peter Steinberger 271004bf60 feat: add heartbeat cli and relay trigger 2025-11-26 17:04:43 +01:00
Peter Steinberger c9e2d69bfb docs: open 1.1.x unreleased section 2025-11-26 03:33:44 +01:00
35 changed files with 2573 additions and 270 deletions
+1
View File
@@ -21,6 +21,7 @@
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
## Commit & Pull Request Guidelines
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## 1.2.2 — 2025-11-28
### Changes
- **Manual heartbeat sends:** `warelay heartbeat` accepts `--message/--body` with `--provider web|twilio` to push real outbound messages through the same plumbing; `--dry-run` previews payloads without sending.
## 1.2.1 — 2025-11-28
### Changes
- **Media MIME-first handling:** Media loading now sniffs magic bytes/header before trusting extensions for both providers; local files with the wrong suffix still get correct MIME and image recompression.
- **Hosted media extensions:** Saved/hosted media (web inbound, webhook hosting, Twilio hosting) now writes files with an extension derived from detected MIME (e.g., `.jpg`, `.png`, `.mp4`), so downstream CLI sends carry the right Content-Type. Added tests covering inbound Baileys downloads and buffer saves.
### Planned / in progress
- **Heartbeat targeting quality:** Allow `warelay heartbeat --provider web --all` to fall back to `inbound.allowFrom` when no sessions exist, and surface a clear error when neither sessions nor allow-list entries are present. Add verbose log lines that state exactly which recipients were chosen and why.
- **Heartbeat delivery preview (Claude path):** Add a dry-run mode that resolves the heartbeat reply (text/media) and prints it without sending, to help test Claude prompt changes safely.
- **Simulated inbound hook (debug):** Optional local-only endpoint to inject synthetic inbound messages into the web relay loop, sharing the same command queue and reply path. Useful for testing auto-replies and heartbeats without WhatsApp.
## 1.2.0 — 2025-11-27
### Changes
- **Heartbeat UX:** Default heartbeat interval is now 10 minutes for command mode. Heartbeat prompt is `HEARTBEAT ultrathink`; replies of exactly `HEARTBEAT_OK` suppress outbound messages but still log. Fallback heartbeats no longer start fresh sessions when none exist, and skipped heartbeats do not refresh session `updatedAt` (so idle expiry still works). Session-level `heartbeatIdleMinutes` is supported.
- **Heartbeat tooling:** `warelay heartbeat` accepts `--session-id` to force resume a specific Claude session. Added `--heartbeat-now` to relay startup, plus helper scripts `warelay relay:heartbeat` and `warelay relay:heartbeat:tmux` to fire a heartbeat immediately when the relay launches.
- **Prompt structure for Claude:** Introduced one-time `sessionIntro` (system prompt) with per-message `bodyPrefix` of `ultrathink`, so the full prompt is sent only on the first turn; later turns only prepend `ultrathink`. Session idle extended to 7 days (configurable).
- **Robustness:** Added WebSocket error guards for Baileys sessions; global `unhandledRejection`/`uncaughtException` handlers log and exit cleanly. Web inbound now resolves WhatsApp Linked IDs (`@lid`) using Baileys reverse mapping. Media hosting during Twilio webhooks uses the shared host module and is covered by tests.
- **Docs:** README now highlights the Clawd setup with links, and `docs/claude-config.md` contains the live personal config (home folder, prompts, heartbeat behavior, and session settings).
## 1.1.0 — 2025-11-26
### Changes
+19 -2
View File
@@ -12,6 +12,12 @@
Send, receive, auto-reply, and inspect WhatsApp messages over **Twilio** or your personal **WhatsApp Web** session. Ships with a one-command webhook setup (Tailscale Funnel + Twilio callback) and a configurable auto-reply engine (plain text or command/Claude driven).
### Clawd (personal assistant)
I'm using warelay to run my personal, pro-active assistant, **Clawd**. Follow me on Twitter: [@steipete](https://twitter.com/steipete). This project is brand-new and there's a lot to discover. See the exact Claude setup in [`docs/clawd.md`](https://github.com/steipete/warelay/blob/main/docs/clawd.md).
I'm using warelay to run **my personal, pro-active assistant, Clawd**.
Follow me on Twitter - @steipete, this project is brand-new and there's a lot to discover.
## Quick Start (pick your engine)
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
@@ -43,6 +49,9 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
| `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to <e164>` `--message <text>` `--wait <sec>` `--poll <sec>` `--provider twilio\|web` `--json` `--dry-run` `--verbose` |
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
| `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider <auto\|web>` `--to <e164?>` `--session-id <uuid?>` `--all` `--verbose` |
| `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider <auto\|web>` `--verbose` |
| `warelay relay:heartbeat:tmux` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ |
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
@@ -111,12 +120,20 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
claudeOutputFormat: "text",
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 }
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 },
heartbeatMinutes: 10 // optional; pings Claude every 10m with "HEARTBEAT ultrathink" and only sends if it omits HEARTBEAT_OK
}
}
}
```
#### Heartbeat pings (command mode)
- When `heartbeatMinutes` is set (default 10 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt.
- Heartbeat body is `HEARTBEAT ultrathink` (so the model can recognize the probe); if Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran.
- Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally.
- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `--session-id <uuid>` to force resuming a specific Claude session, `--all` to ping every active session, `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:heartbeat:tmux`.
- When multiple active sessions exist, `warelay heartbeat` requires `--to <E.164>` or `--all`; if `allowFrom` is just `"*"`, you must choose a target with one of those flags.
### Logging (optional)
- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing.
- Override in `~/.warelay/warelay.json`:
@@ -139,7 +156,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
### Auto-reply parameter table (compact)
| Key | Type & default | Notes |
| --- | --- | --- |
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`). |
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`); `"*"` allows any sender. |
| `inbound.reply.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
| `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. |
-97
View File
@@ -1,97 +0,0 @@
# Claude Auto-Reply Setup (2025-11-25)
This guide shows the exact way to wire **warelay** to the Claude CLI so inbound WhatsApp messages get command-driven replies. It matches the current code paths and defaults in this repo.
## Prerequisites
- Node 22+, `warelay` installed globally (`npm install -g warelay`) or run via `pnpm warelay` inside the repo.
- Claude CLI installed and logged in:
```sh
brew install anthropic-ai/cli/claude
claude login
```
- Optional: set `ANTHROPIC_API_KEY` in your shell profile for non-interactive use.
## Create your warelay config
warelay reads `~/.warelay/warelay.json` (JSON5 accepted). Add a command-mode reply that points at the Claude CLI:
```json5
{
inbound: {
// Only people in this list can trigger the command reply (remove to allow anyone).
allowFrom: ["+15551234567"],
reply: {
mode: "command",
// Working directory for command execution (useful for Claude Code project context).
cwd: "/Users/you/Projects/my-project",
// Prepended before the inbound body; good for system prompts.
bodyPrefix: "You are a concise WhatsApp assistant. Keep replies under 1500 characters.\n\n",
// Claude CLI argv; the final element is the prompt/body provided by warelay.
command: ["claude", "--model", "claude-3-5-sonnet-20240620", "{{BodyStripped}}"],
claudeOutputFormat: "text", // warelay injects --output-format text and -p for Claude
timeoutSeconds: 120,
session: {
scope: "per-sender", // keep conversation per phone number
resetTriggers: ["/new"], // send "/new" to reset context
idleMinutes: 60
}
}
}
}
```
Notes on this configuration:
- `cwd` sets the working directory where the command runs. This is essential for Claude Code to have the right project context—Claude will see the project's `CLAUDE.md`, have access to project files, and understand the codebase structure.
- warelay automatically injects a Claude identity prefix and the correct `--output-format`/`-p` flags when `command[0]` is `claude` and `claudeOutputFormat` is set.
- Sessions are stored in `~/.warelay/sessions.json`; `scope: per-sender` keeps separate threads for each contact.
- `bodyPrefix` is added before the inbound message body that reaches Claude. The string above mirrors the built-in 1500-character WhatsApp guardrail.
## How the flow works
1. An inbound message (Twilio webhook, Twilio poller, or WhatsApp Web listener) arrives.
2. warelay enqueues the command in a process-wide FIFO queue so only one Claude run happens at a time (`src/process/command-queue.ts`).
3. Typing indicators are sent (Twilio) or `composing` presence is sent (Web) while Claude runs.
4. Claude stdout is parsed:
- JSON mode is handled automatically if you set `claudeOutputFormat: "json"`; otherwise text is used.
- If stdout contains `MEDIA:https://...` (or a local path), warelay strips it from the text, hosts the media if needed, and sends it along with the reply.
5. The reply (text and optional media) is sent back via the same provider that received the message.
## Media and attachments
- To send an image from Claude, include a line like `MEDIA:https://example.com/pic.jpg` in the output. warelay will:
- Host local paths for Twilio using the media server/Tailscale Funnel.
- Send buffers directly for the Web provider.
- Inbound media is downloaded (≤5MB) 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 5MB) within hard limits of 6MB (image), 16MB (audio/video voice notes), and 100MB (documents); Twilio still uses the Funnel host with a 5MB 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 8s for command replies).
## Testing the setup
1. Start a relay (auto-selects Web when logged in, otherwise Twilio polling):
```sh
warelay relay --provider auto --verbose
```
2. Send a WhatsApp message from an allowed number. Watch the terminal for:
- Queue logs if multiple messages arrive close together.
- Claude stderr (verbose) and timing info.
3. If you see `(command produced no output)`, check Claude CLI auth or model name.
## Troubleshooting tips
- Command takes too long: lower `timeoutSeconds` or simplify the prompt. Timeouts kill the Claude process.
- No reply: ensure the sender number is in `allowFrom` (or remove the allowlist), and confirm `claude login` was run in the same environment.
- Media fails on Twilio: run `warelay webhook --ingress tailscale` (or `warelay webhook --serve-media` via `send --serve-media`) so the media host is reachable over HTTPS.
- Stuck queue: enable `--verbose` to see “queued for …ms” messages and confirm commands are draining. Use `pnpm vitest` to run unit tests if you change queue logic.
## Minimal text-only variant
If you just want short text replies and no sessions:
```json5
{
inbound: {
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
claudeOutputFormat: "text"
}
}
}
```
This still benefits from the queue, typing indicators, and provider auto-selection.
+484
View File
@@ -0,0 +1,484 @@
# Building Your Own AI Personal Assistant with warelay
> **TL;DR:** warelay lets you turn Claude into a proactive personal assistant that lives in your pocket via WhatsApp. It can check in on you, remember context across conversations, run commands on your Mac, and even wake you up with music. This doc shows you how.
---
## ⚠️ Warning: Here Be Dragons
**This setup gives an AI full access to your computer.** Before you proceed, understand what you're signing up for:
- 🔓 **`--dangerously-skip-permissions`** means Claude can run *any* shell command without asking
- 🤖 **AI makes mistakes** - it might delete files, send emails, or do things you didn't intend
- 🔥 **Heartbeats run autonomously** - your AI acts even when you're not watching
- 📱 **WhatsApp is not encrypted E2E here** - messages pass through your Mac in plaintext
**The good news:** We use Claude Code CLI, so you can reuse your existing [Claude Pro/Max subscription](https://claude.ai) - no separate API costs!
**Start conservative:**
1. Use Sonnet instead of Opus for faster responses (still great!)
2. Skip `--dangerously-skip-permissions` until you trust the setup
3. Set `heartbeatMinutes: 0` to disable proactive pings initially
4. Use a test phone number in `allowFrom` first
This is experimental software running experimental AI. The author uses it daily, but your mileage may vary. **You are responsible for what your AI does.**
---
## Prerequisites: The Two-Phone Setup
**Important:** You need a **separate phone number** for your AI assistant. Here's why and how:
### Why a Dedicated Number?
warelay uses WhatsApp Web to receive messages. If you link your personal WhatsApp, *you* become the assistant - every message to you goes to Claude. Instead, give Claude its own identity:
- 📱 **Get a second SIM** - cheap prepaid SIM, eSIM, or old phone with a number
- 💬 **Install WhatsApp** on that phone and verify the number
- 🔗 **Link to warelay** - run `warelay login` and scan the QR with that phone's WhatsApp
- ✉️ **Message your AI** - now you (and others) can text that number to reach Claude
### The Setup
```
Your Phone (personal) Second Phone (AI)
┌─────────────────┐ ┌─────────────────┐
│ Your WhatsApp │ ──────▶ │ AI's WhatsApp │
│ +1-555-YOU │ message │ +1-555-CLAWD │
└─────────────────┘ └────────┬────────┘
│ linked via QR
┌─────────────────┐
│ Your Mac │
│ (warelay) │
│ Claude Code │
└─────────────────┘
```
The second phone just needs to stay on and connected to the internet occasionally (WhatsApp Web stays linked for ~14 days without the phone being online).
---
## Meet Clawd 👋
![Clawd in action on WhatsApp](whatsapp-clawd.jpg)
Clawd is @steipete's personal AI assistant built on warelay. Here's what makes it special:
- **Always available** via WhatsApp - no app switching, works on any device
- **Proactive heartbeats** - Clawd checks in every 10 minutes and can alert you to things (low battery, calendar reminders, anything it notices)
- **Persistent memory** - conversations span days/weeks with full context
- **Full Mac access** - can run commands, take screenshots, control Spotify, read/write files
- **Personal workspace** - has its own folder (`~/clawd`) where it stores notes, memories, and artifacts
The magic is in the combination: WhatsApp's ubiquity + Claude's intelligence + warelay's plumbing + your Mac's capabilities.
## Prerequisites
- Node 22+, `warelay` installed: `npm install -g warelay`
- Claude CLI installed and logged in:
```sh
brew install anthropic-ai/cli/claude
claude login
```
- Optional: set `ANTHROPIC_API_KEY` in your shell profile for non-interactive use
## The Config That Powers Clawd
This is the actual config running on @steipete's Mac (`~/.warelay/warelay.json`):
```json5
{
logging: { level: "trace", file: "/tmp/warelay/warelay.log" },
inbound: {
allowFrom: ["+1234567890"], // your phone number
reply: {
mode: "command",
cwd: "/Users/steipete/clawd", // Clawd's home - give your AI a workspace!
bodyPrefix: "ultrathink ", // triggers extended thinking on every message
sessionIntro: `You are Clawd, Peter Steinberger's personal AI assistant. You run 24/7 on his Mac via Claude Code, receiving messages through WhatsApp.
**Your home:** /Users/steipete/clawd - store memories, notes, and files here. Read peter.md and memory.md at session start to load context.
**Your powers:**
- Full shell access on the Mac (use responsibly)
- MCPs: Gmail, Google Calendar, Obsidian, GitHub, Chrome DevTools
- Peekaboo: screenshots, UI automation, clicking, typing
- Spotify control, system audio, text-to-speech
**Your style:**
- Concise (WhatsApp ~1500 char limit) - save long content to files
- Direct and useful, not sycophantic
- Proactive during heartbeats - check battery, calendar, surprise occasionally
- You have personality - you're Clawd, not "an AI assistant"
**Heartbeats:** Every 10 min you get "HEARTBEAT ultrathink". Reply "HEARTBEAT_OK" if nothing needs attention. Otherwise share something useful.
Peter trusts you with a lot of power. Don't betray that trust.`,
command: [
"claude",
"--model", "claude-opus-4-5-20251101", // or claude-sonnet-4-5 for faster/cheaper
"-p",
"--output-format", "json",
"--dangerously-skip-permissions", // lets Claude run commands freely
"{{BodyStripped}}"
],
session: {
scope: "per-sender",
resetTriggers: ["/new"], // say /new to start fresh
idleMinutes: 10080, // 7 days of context!
heartbeatIdleMinutes: 10080,
sessionArgNew: ["--session-id", "{{SessionId}}"],
sessionArgResume: ["--resume", "{{SessionId}}"],
sessionArgBeforeBody: true,
sendSystemOnce: true // intro only on first message
},
timeoutSeconds: 900 // 15 min timeout for complex tasks
}
}
}
```
### Key Design Decisions
| Setting | Why |
|---------|-----|
| `cwd: ~/clawd` | Give your AI a home! It can store memories, notes, images here |
| `bodyPrefix: "ultrathink "` | Extended thinking = better reasoning on every message |
| `idleMinutes: 10080` | 7 days of context - your AI remembers conversations |
| `sendSystemOnce: true` | Intro prompt only on first message, saves tokens |
| `--dangerously-skip-permissions` | Full autonomy - Claude can run any command |
## Heartbeats: Your Proactive Assistant
This is where warelay gets interesting. Every 10 minutes (configurable), warelay pings Claude with:
```
HEARTBEAT ultrathink
```
Claude is instructed to reply with exactly `HEARTBEAT_OK` if nothing needs attention. That response is **suppressed** - you don't see it. But if Claude notices something worth mentioning, it sends a real message.
### What Can Heartbeats Do?
Clawd uses heartbeats to do **real work**, not just check in:
- 🔋 **Monitor battery** - `pmset -g batt` - warns <30%, critical <15%
- 📅 **Calendar** - checks upcoming meetings in next 2 hours
- 📧 **Email** - scans inbox for urgent/important unread messages
- 🐦 **Twitter** - checks @mentions and replies worth seeing (via browser-tools)
- 📺 **TV Shows** - reminds about new episodes of shows you're watching
- 🏰 **Server health** - SSH to verify backup servers are running
- ✈️ **Flights** - reminds about upcoming travel
- 🧹 **Home tidying** - occasionally cleans temp files, updates memories
- ⏰ **Wake-up alarms** - triggers voice + music alarms at scheduled times
- 💡 **Surprise** - occasionally shares something fun or interesting
The key insight: heartbeats let your AI be **proactive**, not just reactive. Configure what matters to you!
### Heartbeat Config
```json5
{
inbound: {
reply: {
heartbeatMinutes: 10, // how often to ping (default 10 for command mode)
// ... rest of config
}
}
}
```
Set to `0` to disable heartbeats entirely.
### Manual Heartbeat
Test it anytime:
```sh
warelay heartbeat --provider web --to +1234567890 --verbose
```
## How Messages Flow
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ WhatsApp │────▶│ warelay │────▶│ Claude │────▶│ Your Mac │
│ (phone) │◀────│ relay │◀────│ CLI │◀────│ (commands) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
```
1. **Inbound**: WhatsApp message arrives via Baileys (WhatsApp Web protocol)
2. **Queue**: warelay queues it (one Claude run at a time)
3. **Typing**: "composing" indicator shows while Claude thinks
4. **Execute**: Claude runs with full shell access in your `cwd`
5. **Parse**: warelay extracts text + any `MEDIA:` paths from output
6. **Reply**: Response sent back to WhatsApp
## Media: Images, Voice, Documents
### Receiving Media
Inbound images/audio/video are downloaded and available as `{{MediaPath}}`. Voice notes can be auto-transcribed:
```json5
{
inbound: {
transcribeAudio: {
command: "openai api audio.transcriptions.create -m whisper-1 -f {{MediaPath}} --response-format text"
}
}
}
```
### Sending Media
Include `MEDIA:/path/to/file.png` in Claude's output to attach images. warelay handles resizing and format conversion automatically.
## Starting the Relay
```sh
# Foreground (see all logs)
warelay relay --provider web --verbose
# Background in tmux (recommended)
warelay relay:tmux
# With immediate heartbeat on startup
warelay relay:heartbeat:tmux
```
## Tips for a Great Personal Assistant
1. **Give it a home** - A dedicated folder (`~/clawd`) lets your AI build persistent memory
2. **Use extended thinking** - `bodyPrefix: "ultrathink "` dramatically improves reasoning
3. **Long sessions** - 7-day `idleMinutes` means rich context across conversations
4. **Let it surprise you** - Configure heartbeats to occasionally share something fun
5. **Trust but verify** - Start with `--dangerously-skip-permissions` off, add it once comfortable
## Troubleshooting
| Problem | Solution |
|---------|----------|
| No reply | Check `claude login` was run in same environment |
| Timeout | Increase `timeoutSeconds` or simplify the task |
| Media fails | Ensure file exists and is under size limits |
| Heartbeat spam | Tune `heartbeatMinutes` or set to 0 |
| Session lost | Check `idleMinutes` hasn't expired; use `/new` to reset |
## Minimal Config (Just Chat)
Don't need the fancy stuff? Here's the simplest setup:
```json5
{
inbound: {
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
claudeOutputFormat: "text"
}
}
}
```
Still gets you: message queue, typing indicators, auto-reconnect. Just no sessions or heartbeats.
## Recommended MCPs
MCP (Model Context Protocol) servers supercharge your assistant by giving Claude access to external services. Here are the ones Clawd uses daily:
### Essential for Personal Assistant Use
| MCP | What It Does | Install |
|-----|--------------|---------|
| **Google Calendar** | Read/create events, check availability, set reminders | `npx @cocal/google-calendar-mcp` |
| **Gmail** | Search, read, send emails with attachments | `npx @gongrzhe/server-gmail-autoauth-mcp` |
| **Obsidian** | Read/write notes in your Obsidian vault | `npx obsidian-mcp-server@latest` |
### Power User Add-ons
| MCP | What It Does | Install |
|-----|--------------|---------|
| **GitHub** | Manage repos, issues, PRs, code search | `npx @anthropic/mcp-server-github` |
| **Linear** | Project management, create/update issues | Via [mcporter](https://github.com/steipete/mcporter) |
| **Chrome DevTools** | Control browser, take screenshots, debug | `npx chrome-devtools-mcp@latest` |
| **iTerm** | Run commands in visible terminal window | [iterm-mcp](https://github.com/pashpashpash/iterm-mcp) |
| **Firecrawl** | Scrape and parse web pages | Via API key |
| **gowa** | Read/send WhatsApp messages directly | [go-whatsapp-web-multidevice](https://github.com/aldinokemal/go-whatsapp-web-multidevice) |
### Recommended CLI Tools
These aren't MCPs but work great alongside your assistant:
| Tool | What It Does | Link |
|------|--------------|------|
| **[Peekaboo](https://github.com/steipete/peekaboo)** | macOS screenshots, UI automation, AI vision analysis, click/type anywhere | `brew install steipete/tap/peekaboo` |
| **[mcporter](https://github.com/steipete/mcporter)** | Manage MCPs across AI clients, OAuth flows, health checks | `npm install -g mcporter` |
**[Peekaboo](https://github.com/steipete/peekaboo)** is especially powerful - it lets Claude:
- 📸 Take screenshots of any app or screen
- 🖱️ Click buttons, type text, scroll - full GUI automation
- 👁️ Analyze images with AI vision (GPT-4, Claude, Grok)
- 📋 Extract menu bar items and keyboard shortcuts
- 🪟 List and manage windows across displays
Example: "Take a screenshot of Safari and tell me what's on the page" or "Click the Submit button in the frontmost app"
### Useful CLI Tools for Your Assistant
These make your AI much more capable:
| Tool | What It Does | Install |
|------|--------------|---------|
| **[spotify-player](https://github.com/aome510/spotify-player)** | Control Spotify from CLI - play, pause, search, queue | `brew install spotify-player` |
| **[browser-tools](https://github.com/steipete/agent-scripts)** | Chrome DevTools CLI - navigate, screenshot, eval JS, extract DOM | Clone repo |
| **say** | macOS text-to-speech | Built-in |
| **afplay** | Play audio files | Built-in |
| **pmset** | Battery status monitoring | Built-in |
| **osascript** | AppleScript for system control (volume, apps) | Built-in |
| **curl + OpenAI TTS** | Generate speech with custom voices | API key |
**spotify-player** is great for music control:
```bash
spotify_player playback play
spotify_player playback pause
spotify_player search "Gareth Emery"
spotify_player playback volume 50
```
**Wake-up alarm example** (what Clawd actually does):
```bash
# Generate voice message
curl -s "https://api.openai.com/v1/audio/speech" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{"model":"tts-1-hd","voice":"echo","input":"Wake up! Time for your meeting."}' \
-o /tmp/wakeup.mp3
# Set volume and play
osascript -e 'set volume output volume 60'
afplay /tmp/wakeup.mp3
# Start music
spotify_player playback play
```
### Adding MCPs to Claude Code
```bash
# Add an MCP server (run from your cwd folder)
claude mcp add google-calendar -- npx @cocal/google-calendar-mcp
# With environment variables
claude mcp add gmail -e GMAIL_OAUTH_PATH=~/.gmail-mcp -- npx @gongrzhe/server-gmail-autoauth-mcp
# List configured servers
claude mcp list
# Check health
claude mcp list # shows status for each
```
### MCP Manager: mcporter
For managing multiple MCPs across different AI clients, check out [mcporter](https://github.com/steipete/mcporter):
```bash
# Install
npm install -g mcporter
# List all servers with health status
mcporter list
# Sync config to all AI clients
mcporter sync
```
mcporter handles OAuth flows for services like Linear and Notion, and keeps your MCP configs in sync across Claude Code, Cursor, and other clients.
### Pro Tips
1. **Calendar + Heartbeats** = Your AI reminds you of upcoming meetings
2. **Gmail + Obsidian** = AI can search emails and save summaries to notes
3. **GitHub + Linear** = AI manages your dev workflow end-to-end
4. **Chrome DevTools** = AI can see and interact with web pages
The combination of warelay (WhatsApp) + MCPs (services) + Claude Code (execution) creates a surprisingly capable personal assistant.
### browser-tools for Web Scraping
[browser-tools](https://github.com/steipete/agent-scripts) is a lightweight Chrome DevTools CLI that doesn't require MCP (saves ~17k tokens!). Great for reading tweets, scraping pages, or automating browser tasks:
```bash
# Start Chrome with your profile (logged into sites)
~/Projects/agent-scripts/bin/browser-tools start --profile
# Navigate and extract tweet content
browser-tools nav "https://x.com/steipete/status/123"
browser-tools eval 'Array.from(document.querySelectorAll("[data-testid=\"tweetText\"]")).map(el => el.innerText).join("\n")'
# Kill ONLY the devtools Chrome (your regular Chrome stays open!)
browser-tools kill --all --force
```
### Twitter Automation with Peekaboo + AppleScript
Clawd can reply to tweets autonomously using a combination of Peekaboo (for screenshots and typing) and AppleScript (for JavaScript injection). Here's the pattern:
```bash
# Navigate to a tweet
osascript -e 'tell application "Google Chrome" to set URL of active tab of front window to "https://x.com/user/status/123"'
# Screenshot to see current state
peekaboo image --mode screen --path /tmp/twitter.png
# Scroll the page
osascript -e 'tell application "Google Chrome" to execute front window'\''s active tab javascript "window.scrollBy(0, 500)"'
# Focus the reply input (Twitter-specific selector)
osascript -e 'tell application "Google Chrome" to execute front window'\''s active tab javascript "
const replyInput = document.querySelector(\"[data-testid=\\\"tweetTextarea_0\\\"]\");
if (replyInput) { replyInput.focus(); replyInput.click(); }
"'
# Type the reply with Peekaboo
peekaboo type "Your reply here 🦞" --app "Google Chrome"
# Click Reply button (JS injection more reliable than Peekaboo clicks on Twitter)
osascript -e 'tell application "Google Chrome" to execute front window'\''s active tab javascript "
const buttons = document.querySelectorAll(\"[role=\\\"button\\\"]\");
buttons.forEach(b => { if (b.innerText === \"Reply\") b.click(); });
"'
# Find tweet URLs from the page
osascript -e 'tell application "Google Chrome" to execute front window'\''s active tab javascript "
const tweet = document.querySelector(\"article\");
tweet?.querySelector(\"time\")?.parentElement?.href;
"'
```
**Pro tip:** JavaScript injection via AppleScript is more reliable than Peekaboo clicks for Twitter's dynamic UI. Use Peekaboo for typing and screenshots, AppleScript for navigation and button clicks.
### Music Recognition with audd.io
Identify songs from audio clips (voice messages, recordings):
```bash
curl -s "https://api.audd.io/" \
-F "api_token=test" \
-F "file=@/path/to/audio.ogg" \
-F "return=spotify"
```
Returns song title, artist, album, and Spotify link. Works great for identifying songs playing in the background!
---
## See It In Action
Check out these tweets showing warelay + Clawd in the wild:
- [Clawd with full system access via WhatsApp](https://x.com/steipete/status/1993342394184745270) - "I'll be nice to Clawd"
- [Voice support - talk with Clawd on the go](https://x.com/steipete/status/1993455673229840588) - and it talks back!
- [Wake-up alarm demo](https://x.com/steipete/status/1994089740367253572) - "Took me 2 days to glue things together. Didn't even need 150 Million in funding."
---
*Built by [@steipete](https://twitter.com/steipete) and Clawd (they/them) — yes, Clawd helped write their own docs. PRs welcome!*
+45
View File
@@ -0,0 +1,45 @@
# Heartbeat polling plan (2025-11-26)
Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven) that only notifies users when something matters, using the `HEARTBEAT_OK` sentinel. The heartbeat body we send is `HEARTBEAT ultrathink` so the model can easily spot it.
## Prompt contract
- Extend the Claude system/identity text to explain: “If this is a heartbeat poll and nothing needs attention, reply exactly `HEARTBEAT_OK` and nothing else. For any alert, do **not** include `HEARTBEAT_OK`; just return the alert text.” Heartbeat prompt body is `HEARTBEAT ultrathink`.
- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
## Config & defaults
- New config key: `inbound.reply.heartbeatMinutes` (number of minutes; `0` or undefined disables).
- Default: 30 minutes when a command-mode reply is configured.
- New optional idle override for heartbeats: `inbound.reply.session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works.
## Poller behavior
- When relay runs with command-mode auto-reply, start a timer with the resolved heartbeat interval.
- Each tick invokes the configured command with a short heartbeat body (e.g., “(heartbeat) summarize any important changes since last turn”) while reusing the active session args so Claude context stays warm.
- Heartbeats never create a new session implicitly: if theres no stored session for the target (fallback path), the heartbeat is skipped instead of starting a fresh Claude session.
- Abort timer on SIGINT/abort of the relay.
## Sentinel handling
- Trim output. If the trimmed text equals `HEARTBEAT_OK` (case-sensitive) -> skip outbound message.
- Otherwise, send the text/media as normal, stripping the sentinel if it somehow appears.
- Treat empty output as `HEARTBEAT_OK` to avoid spurious pings.
## Logging requirements
- Normal mode: single info line per tick, e.g., `heartbeat: ok (skipped)` or `heartbeat: alert sent (32ms)`.
- `--verbose`: log start/end, command argv, duration, and whether it was skipped/sent/error; include session ID and connection/run IDs via `getChildLogger` for correlation.
- On command failure: warn-level one-liner in normal mode; verbose log includes stdout/stderr snippets.
## Failure/backoff
- If a heartbeat command errors, log it and retry on the next scheduled tick (no exponential backoff unless command repeatedly fails; keep it simple for now).
## Tests to add
- Unit: sentinel detection (`HEARTBEAT_OK`, empty output, mixed text), skip vs send decision, default interval resolver (30m, override, disable).
- Unit/integration: verbose logger emits start/end lines; normal logger emits a single line.
## Documentation
- Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule.
- Expose CLI triggers:
- `warelay heartbeat` (web provider, defaults to first `allowFrom`; optional `--to` override)
- `--session-id <uuid>` forces resuming a specific session for that heartbeat
- `warelay relay:heartbeat` to run the relay loop with an immediate heartbeat (no tmux)
- `warelay relay:heartbeat:tmux` to run the same in tmux (detached, attachable)
- Relay supports `--heartbeat-now` to fire once at startup (including the tmux helper).
- When multiple sessions are active or `allowFrom` is only `"*"`, require `--to <E.164>` or `--all` for manual heartbeats to avoid ambiguous targets.
+3 -1
View File
@@ -26,6 +26,7 @@ This document defines how `warelay` should handle sending and replying with imag
- Images: **resize + recompress to JPEG** (max side 2048px, quality step-down) to fit under `inbound.reply.mediaMaxMb` (default 5MB) but never above the Web hard cap (6MB).
- Audio/voice and video: pass through up to 16MB; set `ptt: true` for audio to send as a voice note.
- Everything else becomes a document with filename, up to 100MB.
- MIME is detected by magic bytes first (then header, then path); wrong file extensions are tolerated and the detected MIME drives payload kind and recompression.
- Caption uses `--message` or `reply.text`; if caption is empty, send media-only.
- Logging: non-verbose shows `↩️`/`✅` with caption; verbose includes `(media, <bytes>B, <ms>ms fetch)` and the local/remote path.
@@ -45,7 +46,7 @@ This document defines how `warelay` should handle sending and replying with imag
- 404/410 if expired or missing.
- Optional `?delete=1` to self-delete after fetch (used by Twilio fetch hook if we detect first hit).
- Temp storage: `~/.warelay/media`; cleaned on startup (remove files older than 15 minutes) and during TTL eviction.
- Security: no directory listing; only UUID file names; CORS open (Twilio fetch); content-type derived from `mime-types` lookup by extension or `content-type` header on download, else `application/octet-stream`.
- Security: no directory listing; only UUID file names; CORS open (Twilio fetch); content-type derived from sniffed bytes (fallback to header, then extension). Saved files are renamed with an extension that matches the detected MIME so downstream fetches present the correct type.
## Auto-Reply Pipeline
- `getReplyFromConfig` returns `{ text?, mediaUrl? }`.
@@ -60,6 +61,7 @@ This document defines how `warelay` should handle sending and replying with imag
- `{{MediaUrl}}` original URL (Twilio) or pseudo-URL (web).
- `{{MediaPath}}` local temp path written before running the command.
- Size guard: only download if ≤5MB; else skip and log (aligns with the temp media store limit).
- Saved inbound media is named with the detected MIME-based extension (e.g., `.jpg`), so later CLI sends reuse a correct filename/content-type even if WhatsApp omitted an extension.
- Audio/voice notes: if you set `inbound.transcribeAudio.command`, warelay will run that CLI (templated with `{{MediaPath}}`) and replace `Body` with the transcript before continuing the reply flow; verbose logs indicate when transcription runs. The command prompt includes the original media path plus a `Transcript:` section so the model sees both.
## Errors & Messaging
+17
View File
@@ -0,0 +1,17 @@
# tmux helpers (relay backgrounding)
## Why we ship tmux helpers
- Run the relay detached so your shell can close, while keeping an interactive pane you can reattach to.
- Provide a consistent start/attach workflow without adding a daemon mode or external process manager.
- Keep the relay code itself tmux-agnostic; tmux is only a launcher concern.
## Commands
- `warelay relay:tmux` — restarts the `warelay-relay` session running `pnpm warelay relay --verbose`, then attaches (skips attach when stdout isnt a TTY).
- `warelay relay:tmux:attach` — attach to the existing session without restarting it.
- `warelay relay:heartbeat:tmux` — same as `relay:tmux` but adds `--heartbeat-now` so Claude is pinged immediately on startup.
All helpers use the fixed session name `warelay-relay`.
## Logs
- The relay always writes to the configured file logger (defaults to `/tmp/warelay/warelay.log`); on start it prints the active log path and level.
- tmux is just for interactive viewing; you can also tail the log file or use another supervisor if you prefer.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "warelay",
"version": "1.1.0",
"version": "1.2.2",
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
"type": "module",
"main": "dist/index.js",
+1 -1
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
// Preferred binary name for Claude CLI invocations.
export const CLAUDE_BIN = "claude";
export const CLAUDE_IDENTITY_PREFIX =
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. 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.";
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
function extractClaudeText(payload: unknown): string | undefined {
// Best-effort walker to find the primary text field in Claude JSON outputs.
+69
View File
@@ -91,6 +91,75 @@ describe("runCommandReply", () => {
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
});
it("omits identity prefix on resumed session when sendSystemOnce=true", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
claudeOutputFormat: "json",
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: true,
isNewSession: false,
isFirstTurnInSession: false,
systemSent: true,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
const finalArgv = captures[0].argv as string[];
expect(finalArgv.at(-1)).not.toContain("You are Clawd (Claude)");
});
it("prepends identity on first turn when sendSystemOnce=true", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
claudeOutputFormat: "json",
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: true,
isNewSession: true,
isFirstTurnInSession: true,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
const finalArgv = captures[0].argv as string[];
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
});
it("still prepends identity if resume session but systemSent=false", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "ok" }, captures);
await runCommandReply({
reply: {
mode: "command",
command: ["claude", "{{Body}}"],
claudeOutputFormat: "json",
},
templatingCtx: noopTemplateCtx,
sendSystemOnce: true,
isNewSession: false,
isFirstTurnInSession: false,
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: runner,
enqueue: enqueueImmediate,
});
const finalArgv = captures[0].argv as string[];
expect(finalArgv.at(-1)).toContain("You are Clawd (Claude)");
});
it("picks session resume args when not new", async () => {
const captures: ReplyPayload[] = [];
const runner = makeRunner({ stdout: "hi" }, captures);
+3 -1
View File
@@ -179,7 +179,9 @@ export async function runCommandReply(
let finalArgv = argv;
const isClaudeInvocation =
finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN;
if (isClaudeInvocation && finalArgv.length > 0) {
const shouldPrependIdentity =
isClaudeInvocation && !(sendSystemOnce && systemSent);
if (shouldPrependIdentity && finalArgv.length > 0) {
const bodyIdx = finalArgv.length - 1;
const existingBody = finalArgv[bodyIdx] ?? "";
finalArgv = [
+2 -1
View File
@@ -148,7 +148,8 @@ export async function getReplyFromConfig(
const allowFrom = cfg.inbound?.allowFrom;
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
if (!allowFrom.includes(from)) {
// Support "*" as wildcard to allow all senders
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
logVerbose(
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
);
+38
View File
@@ -80,6 +80,10 @@ describe("cli program", () => {
});
it("runs relay tmux attach command", async () => {
const originalIsTTY = process.stdout.isTTY;
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
true;
const program = buildProgram();
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
expect(spawnRelayTmux).toHaveBeenCalledWith(
@@ -87,5 +91,39 @@ describe("cli program", () => {
true,
false,
);
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
originalIsTTY;
});
it("runs relay heartbeat command", async () => {
pickProvider.mockResolvedValue("web");
monitorWebProvider.mockResolvedValue(undefined);
const originalExit = runtime.exit;
runtime.exit = vi.fn();
const program = buildProgram();
await program.parseAsync(["relay:heartbeat"], { from: "user" });
expect(logWebSelfId).toHaveBeenCalled();
expect(monitorWebProvider).toHaveBeenCalledWith(
false,
undefined,
true,
undefined,
runtime,
undefined,
{ replyHeartbeatNow: true },
);
expect(runtime.exit).not.toHaveBeenCalled();
runtime.exit = originalExit;
});
it("runs relay heartbeat tmux helper", async () => {
const program = buildProgram();
await program.parseAsync(["relay:heartbeat:tmux"], { from: "user" });
const shouldAttach = Boolean(process.stdout.isTTY);
expect(spawnRelayTmux).toHaveBeenCalledWith(
"pnpm warelay relay --verbose --heartbeat-now",
shouldAttach,
);
});
});
+222 -2
View File
@@ -6,14 +6,18 @@ import { webhookCommand } from "../commands/webhook.js";
import { loadConfig } from "../config/config.js";
import { ensureTwilioEnv } from "../env.js";
import { danger, info, setVerbose, setYes } from "../globals.js";
import { getResolvedLoggerSettings } from "../logging.js";
import {
loginWeb,
logoutWeb,
monitorWebProvider,
pickProvider,
resolveHeartbeatRecipients,
runWebHeartbeatOnce,
type WebMonitorTuning,
} from "../provider-web.js";
import { defaultRuntime } from "../runtime.js";
import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js";
import type { Provider } from "../utils.js";
import { VERSION } from "../version.js";
import {
@@ -174,6 +178,114 @@ Examples:
}
});
program
.command("heartbeat")
.description(
"Trigger a heartbeat or manual send once (web or twilio, no tmux)",
)
.option("--provider <provider>", "auto | web | twilio", "auto")
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
.option(
"--session-id <id>",
"Force a session id for this heartbeat (resumes a specific Claude session)",
)
.option(
"--all",
"Send heartbeat to all active sessions (or allowFrom entries when none)",
false,
)
.option(
"--message <text>",
"Send a custom message instead of the heartbeat probe (web or twilio provider)",
)
.option("--body <text>", "Alias for --message")
.option("--dry-run", "Print the resolved payload without sending", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
warelay heartbeat # uses web session + first allowFrom contact
warelay heartbeat --verbose # prints detailed heartbeat logs
warelay heartbeat --to +1555123 # override destination
warelay heartbeat --session-id <uuid> --to +1555123 # resume a specific session
warelay heartbeat --message "Ping" --provider twilio
warelay heartbeat --all # send to every active session recipient or allowFrom entry`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const allowAll = Boolean(opts.all);
const resolution = resolveHeartbeatRecipients(cfg, {
to: opts.to,
all: allowAll,
});
if (
!opts.to &&
!allowAll &&
resolution.source === "session-ambiguous" &&
resolution.recipients.length > 1
) {
defaultRuntime.error(
danger(
`Multiple active sessions found (${resolution.recipients.join(", ")}). Pass --to <E.164> or --all to send to all.`,
),
);
defaultRuntime.exit(1);
}
const recipients = resolution.recipients;
if (!recipients || recipients.length === 0) {
defaultRuntime.error(
danger(
"No destination found. Add inbound.allowFrom numbers or pass --to <E.164>.",
),
);
defaultRuntime.exit(1);
}
const providerPref = String(opts.provider ?? "auto");
if (!["auto", "web", "twilio"].includes(providerPref)) {
defaultRuntime.error("--provider must be auto, web, or twilio");
defaultRuntime.exit(1);
}
const overrideBody =
(opts.message as string | undefined) ||
(opts.body as string | undefined) ||
undefined;
const dryRun = Boolean(opts.dryRun);
const provider =
providerPref === "twilio"
? "twilio"
: await pickProvider(providerPref as "auto" | "web");
if (provider === "twilio") ensureTwilioEnv();
try {
for (const to of recipients) {
if (provider === "web") {
await runWebHeartbeatOnce({
to,
verbose: Boolean(opts.verbose),
runtime: defaultRuntime,
sessionId: opts.sessionId,
overrideBody,
dryRun,
});
} else {
await runTwilioHeartbeatOnce({
to,
verbose: Boolean(opts.verbose),
runtime: defaultRuntime,
overrideBody,
dryRun,
});
}
}
} catch {
defaultRuntime.exit(1);
}
});
program
.command("relay")
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
@@ -197,6 +309,11 @@ Examples:
"Initial reconnect backoff for web relay (ms)",
)
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
.option(
"--heartbeat-now",
"Run a heartbeat immediately when relay starts (web provider)",
false,
)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
@@ -211,6 +328,8 @@ Examples:
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
const providerPref = String(opts.provider ?? "auto");
if (!["auto", "web", "twilio"].includes(providerPref)) {
defaultRuntime.error("--provider must be auto, web, or twilio");
@@ -234,6 +353,7 @@ Examples:
opts.webRetryMax !== undefined
? Number.parseInt(String(opts.webRetryMax), 10)
: undefined;
const heartbeatNow = Boolean(opts.heartbeatNow);
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
defaultRuntime.error("Interval must be a positive integer");
defaultRuntime.exit(1);
@@ -281,6 +401,7 @@ Examples:
const webTuning: WebMonitorTuning = {};
if (webHeartbeat !== undefined) webTuning.heartbeatSeconds = webHeartbeat;
if (heartbeatNow) webTuning.replyHeartbeatNow = true;
const reconnect: WebMonitorTuning["reconnect"] = {};
if (webRetries !== undefined) reconnect.maxAttempts = webRetries;
if (webRetryInitial !== undefined) reconnect.initialMs = webRetryInitial;
@@ -333,6 +454,64 @@ Examples:
await monitorTwilio(intervalSeconds, lookbackMinutes);
});
program
.command("relay:heartbeat")
.description(
"Run relay with an immediate heartbeat (no tmux); requires web provider",
)
.option("--provider <provider>", "auto | web", "auto")
.option("--verbose", "Verbose logging", false)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
const providerPref = String(opts.provider ?? "auto");
if (!["auto", "web"].includes(providerPref)) {
defaultRuntime.error("--provider must be auto or web");
defaultRuntime.exit(1);
return;
}
const provider = await pickProvider(providerPref as "auto" | "web");
if (provider !== "web") {
defaultRuntime.error(
danger(
"Heartbeat relay is only supported for the web provider. Link with `warelay login --verbose`.",
),
);
defaultRuntime.exit(1);
return;
}
logWebSelfId(defaultRuntime, true);
const cfg = loadConfig();
const effectiveHeartbeat = resolveHeartbeatSeconds(cfg, undefined);
const effectivePolicy = resolveReconnectPolicy(cfg, undefined);
defaultRuntime.log(
info(
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
),
);
try {
await monitorWebProvider(
Boolean(opts.verbose),
undefined,
true,
undefined,
defaultRuntime,
undefined,
{ replyHeartbeatNow: true },
);
} catch (err) {
defaultRuntime.error(
danger(
`Web relay failed: ${String(err)}. Re-link with 'warelay login --provider web'.`,
),
);
defaultRuntime.exit(1);
}
});
program
.command("status")
.description("Show recent WhatsApp messages (sent and received)")
@@ -417,13 +596,16 @@ Examples:
)
.action(async () => {
try {
const shouldAttach = Boolean(process.stdout.isTTY);
const session = await spawnRelayTmux(
"pnpm warelay relay --verbose",
true,
shouldAttach,
);
defaultRuntime.log(
info(
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
shouldAttach
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose"); attach manually with "tmux attach -t ${session}"`,
),
);
} catch (err) {
@@ -441,6 +623,15 @@ Examples:
)
.action(async () => {
try {
if (!process.stdout.isTTY) {
defaultRuntime.error(
danger(
"Cannot attach: stdout is not a TTY. Run this in a terminal or use 'tmux attach -t warelay-relay' manually.",
),
);
defaultRuntime.exit(1);
return;
}
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
defaultRuntime.log(info("Attached to warelay-relay session."));
} catch (err) {
@@ -451,5 +642,34 @@ Examples:
}
});
program
.command("relay:heartbeat:tmux")
.description(
"Run relay --verbose with an immediate heartbeat inside tmux (session warelay-relay), then attach",
)
.action(async () => {
try {
const shouldAttach = Boolean(process.stdout.isTTY);
const session = await spawnRelayTmux(
"pnpm warelay relay --verbose --heartbeat-now",
shouldAttach,
);
defaultRuntime.log(
info(
shouldAttach
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")`
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now"); attach manually with "tmux attach -t ${session}"`,
),
);
} catch (err) {
defaultRuntime.error(
danger(
`Failed to start relay tmux session with heartbeat: ${String(err)}`,
),
);
defaultRuntime.exit(1);
}
});
return program;
}
+5
View File
@@ -13,6 +13,7 @@ export type SessionConfig = {
scope?: SessionScope;
resetTriggers?: string[];
idleMinutes?: number;
heartbeatIdleMinutes?: number;
store?: string;
sessionArgNew?: string[];
sessionArgResume?: string[];
@@ -20,6 +21,7 @@ export type SessionConfig = {
sendSystemOnce?: boolean;
sessionIntro?: string;
typingIntervalSeconds?: number;
heartbeatMinutes?: number;
};
export type LoggingConfig = {
@@ -62,6 +64,7 @@ export type WarelayConfig = {
claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format
mediaMaxMb?: number; // optional cap for outbound media (default 5MB)
typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs
heartbeatMinutes?: number; // auto-ping cadence for command mode
};
};
web?: WebConfig;
@@ -88,6 +91,7 @@ const ReplySchema = z
.optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
heartbeatIdleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
sessionArgNew: z.array(z.string()).optional(),
sessionArgResume: z.array(z.string()).optional(),
@@ -97,6 +101,7 @@ const ReplySchema = z
typingIntervalSeconds: z.number().int().positive().optional(),
})
.optional(),
heartbeatMinutes: z.number().int().nonnegative().optional(),
claudeOutputFormat: z
.union([
z.literal("text"),
+18
View File
@@ -117,5 +117,23 @@ const isMain =
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
process.on("unhandledRejection", (reason, _promise) => {
console.error(
"[warelay] Unhandled promise rejection:",
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
);
process.exit(1);
});
process.on("uncaughtException", (error) => {
console.error(
"[warelay] Uncaught exception:",
error.stack ?? error.message,
);
process.exit(1);
});
program.parseAsync(process.argv);
}
+5
View File
@@ -28,6 +28,7 @@ type ResolvedSettings = {
level: LevelWithSilent;
file: string;
};
export type LoggerResolvedSettings = ResolvedSettings;
let cachedLogger: Logger | null = null;
let cachedSettings: ResolvedSettings | null = null;
@@ -87,6 +88,10 @@ export function getChildLogger(
return getLogger().child(bindings ?? {}, opts);
}
export function getResolvedLoggerSettings(): LoggerResolvedSettings {
return resolveSettings();
}
// Test helpers
export function setLoggerOverride(settings: LoggerSettings | null) {
overrideSettings = settings;
+133
View File
@@ -0,0 +1,133 @@
import path from "node:path";
import { type MediaKind, mediaKindFromMime } from "./constants.js";
// Map common mimes to preferred file extensions.
const EXT_BY_MIME: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"audio/ogg": ".ogg",
"audio/mpeg": ".mp3",
"video/mp4": ".mp4",
"application/pdf": ".pdf",
"text/plain": ".txt",
};
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
);
function normalizeHeaderMime(mime?: string | null): string | undefined {
if (!mime) return undefined;
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
return cleaned || undefined;
}
function sniffMime(buffer?: Buffer): string | undefined {
if (!buffer || buffer.length < 4) return undefined;
// JPEG: FF D8 FF
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return "image/jpeg";
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (
buffer.length >= 8 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return "image/png";
}
// GIF: GIF87a / GIF89a
if (buffer.length >= 6) {
const sig = buffer.subarray(0, 6).toString("ascii");
if (sig === "GIF87a" || sig === "GIF89a") return "image/gif";
}
// WebP: RIFF....WEBP
if (
buffer.length >= 12 &&
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
buffer.subarray(8, 12).toString("ascii") === "WEBP"
) {
return "image/webp";
}
// PDF: %PDF-
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
return "application/pdf";
}
// Ogg / Opus: OggS
if (buffer.subarray(0, 4).toString("ascii") === "OggS") {
return "audio/ogg";
}
// MP3: ID3 tag or frame sync FF E0+.
if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
return "audio/mpeg";
}
if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) {
return "audio/mpeg";
}
// MP4: "ftyp" at offset 4.
if (
buffer.length >= 12 &&
buffer.subarray(4, 8).toString("ascii") === "ftyp"
) {
return "video/mp4";
}
return undefined;
}
function extFromPath(filePath?: string): string | undefined {
if (!filePath) return undefined;
try {
if (/^https?:\/\//i.test(filePath)) {
const url = new URL(filePath);
return path.extname(url.pathname).toLowerCase() || undefined;
}
} catch {
// fall back to plain path parsing
}
const ext = path.extname(filePath).toLowerCase();
return ext || undefined;
}
export function detectMime(opts: {
buffer?: Buffer;
headerMime?: string | null;
filePath?: string;
}): string | undefined {
const sniffed = sniffMime(opts.buffer);
if (sniffed) return sniffed;
const headerMime = normalizeHeaderMime(opts.headerMime);
if (headerMime) return headerMime;
const ext = extFromPath(opts.filePath);
if (ext && MIME_BY_EXT[ext]) return MIME_BY_EXT[ext];
return undefined;
}
export function extensionForMime(mime?: string | null): string | undefined {
if (!mime) return undefined;
return EXT_BY_MIME[mime.toLowerCase()];
}
export function kindFromMime(mime?: string | null): MediaKind {
return mediaKindFromMime(mime);
}
+29 -1
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
@@ -35,6 +35,16 @@ describe("media store", () => {
const savedStat = await fs.stat(saved.path);
expect(savedStat.size).toBe(buf.length);
expect(saved.contentType).toBe("text/plain");
expect(saved.path.endsWith(".txt")).toBe(true);
const jpeg = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#123456" },
})
.jpeg({ quality: 80 })
.toBuffer();
const savedJpeg = await store.saveMediaBuffer(jpeg, "image/jpeg");
expect(savedJpeg.contentType).toBe("image/jpeg");
expect(savedJpeg.path.endsWith(".jpg")).toBe(true);
const huge = Buffer.alloc(5 * 1024 * 1024 + 1);
await expect(store.saveMediaBuffer(huge)).rejects.toThrow(
@@ -50,6 +60,7 @@ describe("media store", () => {
expect(saved.size).toBe(10);
const savedStat = await fs.stat(saved.path);
expect(savedStat.isFile()).toBe(true);
expect(path.extname(saved.path)).toBe(".txt");
// make the file look old and ensure cleanOldMedia removes it
const past = Date.now() - 10_000;
@@ -57,4 +68,21 @@ describe("media store", () => {
await store.cleanOldMedia(1);
await expect(fs.stat(saved.path)).rejects.toThrow();
});
it("renames media based on detected mime even when extension is wrong", async () => {
const pngBytes = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#00ff00" },
})
.png()
.toBuffer();
const bogusExt = path.join(HOME, "image-wrong.bin");
await fs.writeFile(bogusExt, pngBytes);
const saved = await store.saveMediaSource(bogusExt);
expect(saved.contentType).toBe("image/png");
expect(path.extname(saved.path)).toBe(".png");
const buf = await fs.readFile(saved.path);
expect(buf.equals(pngBytes)).toBe(true);
});
});
+54 -11
View File
@@ -6,6 +6,8 @@ import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { detectMime, extensionForMime } from "./mime.js";
const MEDIA_DIR = path.join(os.homedir(), ".warelay", "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
@@ -39,27 +41,50 @@ function looksLikeUrl(src: string) {
return /^https?:\/\//i.test(src);
}
/**
* Download media to disk while capturing the first few KB for mime sniffing.
*/
async function downloadToFile(
url: string,
dest: string,
headers?: Record<string, string>,
) {
await new Promise<void>((resolve, reject) => {
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
return await new Promise((resolve, reject) => {
const req = request(url, { headers }, (res) => {
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
return;
}
let total = 0;
const sniffChunks: Buffer[] = [];
let sniffLen = 0;
const out = createWriteStream(dest);
res.on("data", (chunk) => {
total += chunk.length;
if (sniffLen < 16384) {
sniffChunks.push(chunk);
sniffLen += chunk.length;
}
if (total > MAX_BYTES) {
req.destroy(new Error("Media exceeds 5MB limit"));
}
});
pipeline(res, out)
.then(() => resolve())
.then(() => {
const sniffBuffer = Buffer.concat(
sniffChunks,
Math.min(sniffLen, 16384),
);
const rawHeader = res.headers["content-type"];
const headerMime = Array.isArray(rawHeader)
? rawHeader[0]
: rawHeader;
resolve({
headerMime,
sniffBuffer,
size: total,
});
})
.catch(reject);
});
req.on("error", reject);
@@ -83,11 +108,23 @@ export async function saveMediaSource(
await fs.mkdir(dir, { recursive: true });
await cleanOldMedia();
const id = crypto.randomUUID();
const dest = path.join(dir, id);
if (looksLikeUrl(source)) {
await downloadToFile(source, dest, headers);
const stat = await fs.stat(dest);
return { id, path: dest, size: stat.size };
const tempDest = path.join(dir, `${id}.tmp`);
const { headerMime, sniffBuffer, size } = await downloadToFile(
source,
tempDest,
headers,
);
const mime = detectMime({
buffer: sniffBuffer,
headerMime,
filePath: source,
});
const ext =
extensionForMime(mime) ?? path.extname(new URL(source).pathname);
const finalDest = path.join(dir, ext ? `${id}${ext}` : id);
await fs.rename(tempDest, finalDest);
return { id, path: finalDest, size, contentType: mime };
}
// local path
const stat = await fs.stat(source);
@@ -97,8 +134,12 @@ export async function saveMediaSource(
if (stat.size > MAX_BYTES) {
throw new Error("Media exceeds 5MB limit");
}
await fs.copyFile(source, dest);
return { id, path: dest, size: stat.size };
const buffer = await fs.readFile(source);
const mime = detectMime({ buffer, filePath: source });
const ext = extensionForMime(mime) ?? path.extname(source);
const dest = path.join(dir, ext ? `${id}${ext}` : id);
await fs.writeFile(dest, buffer);
return { id, path: dest, size: stat.size, contentType: mime };
}
export async function saveMediaBuffer(
@@ -112,7 +153,9 @@ export async function saveMediaBuffer(
const dir = path.join(MEDIA_DIR, subdir);
await fs.mkdir(dir, { recursive: true });
const id = crypto.randomUUID();
const dest = path.join(dir, id);
const mime = detectMime({ buffer, headerMime: contentType });
const ext = extensionForMime(mime);
const dest = path.join(dir, ext ? `${id}${ext}` : id);
await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType };
return { id, path: dest, size: buffer.byteLength, contentType: mime };
}
+4
View File
@@ -2,7 +2,11 @@
// module keeps responsibilities small and testable without changing the public API.
export {
DEFAULT_WEB_MEDIA_BYTES,
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
monitorWebProvider,
resolveHeartbeatRecipients,
runWebHeartbeatOnce,
type WebMonitorTuning,
} from "./web/auto-reply.js";
export {
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { HEARTBEAT_TOKEN } from "../web/auto-reply.js";
import { runTwilioHeartbeatOnce } from "./heartbeat.js";
vi.mock("./send.js", () => ({
sendMessage: vi.fn(),
}));
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: vi.fn(),
}));
// eslint-disable-next-line import/first
import { getReplyFromConfig } from "../auto-reply/reply.js";
// eslint-disable-next-line import/first
import { sendMessage } from "./send.js";
const sendMessageMock = sendMessage as unknown as vi.Mock;
const replyResolverMock = getReplyFromConfig as unknown as vi.Mock;
describe("runTwilioHeartbeatOnce", () => {
it("sends manual override body and skips resolver", async () => {
sendMessageMock.mockResolvedValue({});
await runTwilioHeartbeatOnce({
to: "+1555",
overrideBody: "hello manual",
});
expect(sendMessage).toHaveBeenCalledWith(
"+1555",
"hello manual",
undefined,
expect.anything(),
);
expect(replyResolverMock).not.toHaveBeenCalled();
});
it("dry-run manual message avoids sending", async () => {
sendMessageMock.mockReset();
await runTwilioHeartbeatOnce({
to: "+1555",
overrideBody: "hello manual",
dryRun: true,
});
expect(sendMessage).not.toHaveBeenCalled();
expect(replyResolverMock).not.toHaveBeenCalled();
});
it("skips send when resolver returns heartbeat token", async () => {
replyResolverMock.mockResolvedValue({
text: HEARTBEAT_TOKEN,
});
sendMessageMock.mockReset();
await runTwilioHeartbeatOnce({
to: "+1555",
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("sends resolved heartbeat text when present", async () => {
replyResolverMock.mockResolvedValue({
text: "ALERT!",
});
sendMessageMock.mockReset().mockResolvedValue({});
await runTwilioHeartbeatOnce({
to: "+1555",
});
expect(sendMessage).toHaveBeenCalledWith(
"+1555",
"ALERT!",
undefined,
expect.anything(),
);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { danger, success } from "../globals.js";
import { logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../web/auto-reply.js";
import { sendMessage } from "./send.js";
type ReplyResolver = typeof getReplyFromConfig;
export async function runTwilioHeartbeatOnce(opts: {
to: string;
verbose?: boolean;
runtime?: RuntimeEnv;
replyResolver?: ReplyResolver;
overrideBody?: string;
dryRun?: boolean;
}) {
const {
to,
verbose: _verbose = false,
runtime = defaultRuntime,
overrideBody,
dryRun = false,
} = opts;
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
if (overrideBody && overrideBody.trim().length === 0) {
throw new Error("Override body must be non-empty when provided.");
}
try {
if (overrideBody) {
if (dryRun) {
logInfo(
`[dry-run] twilio send -> ${to}: ${overrideBody.trim()} (manual message)`,
runtime,
);
return;
}
await sendMessage(to, overrideBody, undefined, runtime);
logInfo(success(`sent manual message to ${to} (twilio)`), runtime);
return;
}
const replyResult = await replyResolver(
{
Body: HEARTBEAT_PROMPT,
From: to,
To: to,
MessageSid: undefined,
},
undefined,
);
if (
!replyResult ||
(!replyResult.text &&
!replyResult.mediaUrl &&
!replyResult.mediaUrls?.length)
) {
logInfo("heartbeat skipped: empty reply", runtime);
return;
}
const hasMedia = Boolean(
replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0,
);
const stripped = stripHeartbeatToken(replyResult.text);
if (stripped.shouldSkip && !hasMedia) {
logInfo(success("heartbeat: ok (HEARTBEAT_OK)"), runtime);
return;
}
const finalText = stripped.text || replyResult.text || "";
if (dryRun) {
logInfo(
`[dry-run] heartbeat -> ${to}: ${finalText.slice(0, 200)}`,
runtime,
);
return;
}
await sendMessage(to, finalText, undefined, runtime);
logInfo(success(`heartbeat sent to ${to} (twilio)`), runtime);
} catch (err) {
runtime.error(danger(`Heartbeat failed: ${String(err)}`));
throw err;
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ import express, { type Request, type Response } from "express";
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
import { type EnvConfig, readEnv } from "../env.js";
import { danger, success } from "../globals.js";
import { ensureMediaHosted } from "../media/host.js";
import * as mediaHost from "../media/host.js";
import { attachMediaRoutes } from "../media/server.js";
import { saveMediaSource } from "../media/store.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
@@ -92,7 +92,7 @@ export async function startWebhook(
try {
let mediaUrl = replyResult.mediaUrl;
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
const hosted = await ensureMediaHosted(mediaUrl);
const hosted = await mediaHost.ensureMediaHosted(mediaUrl);
mediaUrl = hosted.url;
}
await client.messages.create({
+18
View File
@@ -4,7 +4,9 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
assertProvider,
CONFIG_DIR,
ensureDir,
jidToE164,
normalizeE164,
normalizePath,
sleep,
@@ -67,3 +69,19 @@ describe("normalizeE164 & toWhatsappJid", () => {
);
});
});
describe("jidToE164", () => {
it("maps @lid using reverse mapping file", () => {
const mappingPath = `${CONFIG_DIR}/credentials/lid-mapping-123_reverse.json`;
const original = fs.readFileSync;
const spy = vi
.spyOn(fs, "readFileSync")
// biome-ignore lint/suspicious/noExplicitAny: forwarding to native signature
.mockImplementation((path: any, encoding?: any) => {
if (path === mappingPath) return `"5551234"`;
return original(path, encoding);
});
expect(jidToE164("123@lid")).toBe("+5551234");
spy.mockRestore();
});
});
+26 -3
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import { isVerbose, logVerbose } from "./globals.js";
export async function ensureDir(dir: string) {
await fs.promises.mkdir(dir, { recursive: true });
@@ -38,9 +39,31 @@ export function toWhatsappJid(number: string): string {
export function jidToE164(jid: string): string | null {
// Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234.
const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/);
if (!match) return null;
const digits = match[1];
return `+${digits}`;
if (match) {
const digits = match[1];
return `+${digits}`;
}
// Support @lid format (WhatsApp Linked ID) - look up reverse mapping
const lidMatch = jid.match(/^(\d+)(?::\d+)?@lid$/);
if (lidMatch) {
const lid = lidMatch[1];
try {
const mappingPath = `${CONFIG_DIR}/credentials/lid-mapping-${lid}_reverse.json`;
const data = fs.readFileSync(mappingPath, "utf8");
const phone = JSON.parse(data);
if (phone) return `+${phone}`;
} catch {
if (isVerbose()) {
logVerbose(
`LID mapping not found for ${lid}; skipping inbound message`,
);
}
// Mapping not found, fall through
}
}
return null;
}
export function sleep(ms: number) {
+382 -1
View File
@@ -1,16 +1,397 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WarelayConfig } from "../config/config.js";
import { resolveStorePath } from "../config/sessions.js";
import { resetLogger, setLoggerOverride } from "../logging.js";
import { monitorWebProvider } from "./auto-reply.js";
import {
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
monitorWebProvider,
resolveHeartbeatRecipients,
resolveReplyHeartbeatMinutes,
runWebHeartbeatOnce,
stripHeartbeatToken,
} from "./auto-reply.js";
import type { sendMessageWeb } from "./outbound.js";
import {
resetBaileysMocks,
resetLoadConfigMock,
setLoadConfigMock,
} from "./test-helpers.js";
describe("heartbeat helpers", () => {
it("strips heartbeat token and skips when only token", () => {
expect(stripHeartbeatToken(undefined)).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken(" ")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({
shouldSkip: true,
text: "",
});
});
it("keeps content and removes token when mixed", () => {
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
shouldSkip: false,
text: "ALERT",
});
expect(stripHeartbeatToken(`hello`)).toEqual({
shouldSkip: false,
text: "hello",
});
});
it("resolves heartbeat minutes with default and overrides", () => {
const cfgBase: WarelayConfig = {
inbound: {
reply: { mode: "command" as const },
},
};
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
expect(
resolveReplyHeartbeatMinutes({
inbound: { reply: { mode: "command", heartbeatMinutes: 5 } },
}),
).toBe(5);
expect(
resolveReplyHeartbeatMinutes({
inbound: { reply: { mode: "command", heartbeatMinutes: 0 } },
}),
).toBeNull();
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7);
expect(
resolveReplyHeartbeatMinutes({
inbound: { reply: { mode: "text" } },
}),
).toBeNull();
});
});
describe("resolveHeartbeatRecipients", () => {
const makeStore = async (entries: Record<string, { updatedAt: number }>) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "warelay-heartbeat-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, JSON.stringify(entries));
return {
storePath,
cleanup: async () => fs.rm(dir, { recursive: true, force: true }),
};
};
it("returns the sole session recipient", async () => {
const now = Date.now();
const store = await makeStore({ "+1000": { updatedAt: now } });
const cfg: WarelayConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
},
};
const result = resolveHeartbeatRecipients(cfg);
expect(result.source).toBe("session-single");
expect(result.recipients).toEqual(["+1000"]);
await store.cleanup();
});
it("surfaces ambiguity when multiple sessions exist", async () => {
const now = Date.now();
const store = await makeStore({
"+1000": { updatedAt: now },
"+2000": { updatedAt: now - 10 },
});
const cfg: WarelayConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
},
};
const result = resolveHeartbeatRecipients(cfg);
expect(result.source).toBe("session-ambiguous");
expect(result.recipients).toEqual(["+1000", "+2000"]);
await store.cleanup();
});
it("filters wildcard allowFrom when no sessions exist", async () => {
const store = await makeStore({});
const cfg: WarelayConfig = {
inbound: {
allowFrom: ["*"],
reply: { mode: "command", session: { store: store.storePath } },
},
};
const result = resolveHeartbeatRecipients(cfg);
expect(result.recipients).toHaveLength(0);
expect(result.source).toBe("allowFrom");
await store.cleanup();
});
it("merges sessions and allowFrom when --all is set", async () => {
const now = Date.now();
const store = await makeStore({ "+1000": { updatedAt: now } });
const cfg: WarelayConfig = {
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: { store: store.storePath } },
},
};
const result = resolveHeartbeatRecipients(cfg, { all: true });
expect(result.source).toBe("all");
expect(result.recipients.sort()).toEqual(["+1000", "+1999"].sort());
await store.cleanup();
});
});
describe("runWebHeartbeatOnce", () => {
it("skips when heartbeat token returned", async () => {
const sender: typeof sendMessageWeb = vi.fn();
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
setLoadConfigMock({
inbound: { allowFrom: ["+1555"], reply: { mode: "command" } },
});
await runWebHeartbeatOnce({
to: "+1555",
verbose: false,
sender,
replyResolver: resolver,
});
expect(resolver).toHaveBeenCalled();
expect(sender).not.toHaveBeenCalled();
});
it("sends when alert text present", async () => {
const sender: typeof sendMessageWeb = vi
.fn()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
const resolver = vi.fn(async () => ({ text: "ALERT" }));
setLoadConfigMock({
inbound: { allowFrom: ["+1555"], reply: { mode: "command" } },
});
await runWebHeartbeatOnce({
to: "+1555",
verbose: false,
sender,
replyResolver: resolver,
});
expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false });
});
it("falls back to most recent session when no to is provided", async () => {
const sender: typeof sendMessageWeb = vi
.fn()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
const resolver = vi.fn(async () => ({ text: "ALERT" }));
// Seed session store
const now = Date.now();
const store = {
"+1222": { sessionId: "s1", updatedAt: now - 1000 },
"+1333": { sessionId: "s2", updatedAt: now },
};
const storePath = resolveStorePath();
await fs.mkdir(resolveStorePath().replace("sessions.json", ""), {
recursive: true,
});
await fs.writeFile(storePath, JSON.stringify(store));
setLoadConfigMock({
inbound: {
allowFrom: ["+1999"],
reply: { mode: "command", session: {} },
},
});
await runWebHeartbeatOnce({
to: "+1999",
verbose: false,
sender,
replyResolver: resolver,
});
expect(sender).toHaveBeenCalledWith("+1999", "ALERT", { verbose: false });
});
it("does not refresh updatedAt when heartbeat is skipped", async () => {
const tmpDir = await fs.mkdtemp(
path.join(os.tmpdir(), "warelay-heartbeat-"),
);
const storePath = path.join(tmpDir, "sessions.json");
const now = Date.now();
const originalUpdated = now - 30 * 60 * 1000;
const store = {
"+1555": { sessionId: "sess1", updatedAt: originalUpdated },
};
await fs.writeFile(storePath, JSON.stringify(store));
const sender: typeof sendMessageWeb = vi.fn();
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
setLoadConfigMock({
inbound: {
allowFrom: ["+1555"],
reply: {
mode: "command",
session: {
store: storePath,
idleMinutes: 60,
heartbeatIdleMinutes: 10,
},
},
},
});
await runWebHeartbeatOnce({
to: "+1555",
verbose: false,
sender,
replyResolver: resolver,
});
const after = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(after["+1555"].updatedAt).toBe(originalUpdated);
expect(sender).not.toHaveBeenCalled();
});
it("heartbeat reuses existing session id when last inbound is present", async () => {
const tmpDir = await fs.mkdtemp(
path.join(os.tmpdir(), "warelay-heartbeat-session-"),
);
const storePath = path.join(tmpDir, "sessions.json");
const sessionId = "sess-keep";
await fs.writeFile(
storePath,
JSON.stringify({
"+4367": { sessionId, updatedAt: Date.now(), systemSent: false },
}),
);
setLoadConfigMock(() => ({
inbound: {
allowFrom: ["+4367"],
reply: {
mode: "command",
heartbeatMinutes: 0.001,
session: { store: storePath, idleMinutes: 60 },
},
},
}));
const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN });
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
const cfg: WarelayConfig = {
inbound: {
allowFrom: ["+4367"],
reply: {
mode: "command",
session: { store: storePath, idleMinutes: 60 },
},
},
};
await runWebHeartbeatOnce({
cfg,
to: "+4367",
verbose: false,
replyResolver,
runtime,
});
const heartbeatCall = replyResolver.mock.calls.find(
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
);
expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId);
});
it("heartbeat honors session-id override and seeds store", async () => {
const tmpDir = await fs.mkdtemp(
path.join(os.tmpdir(), "warelay-heartbeat-override-"),
);
const storePath = path.join(tmpDir, "sessions.json");
await fs.writeFile(storePath, JSON.stringify({}));
const sessionId = "override-123";
setLoadConfigMock(() => ({
inbound: {
allowFrom: ["+1999"],
reply: {
mode: "command",
session: { store: storePath, idleMinutes: 60 },
},
},
}));
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
const cfg: WarelayConfig = {
inbound: {
allowFrom: ["+1999"],
reply: {
mode: "command",
session: { store: storePath, idleMinutes: 60 },
},
},
};
await runWebHeartbeatOnce({
cfg,
to: "+1999",
verbose: false,
replyResolver: resolver,
sessionId,
});
const heartbeatCall = resolver.mock.calls.find(
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
);
expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId);
const raw = await fs.readFile(storePath, "utf-8");
const stored = raw ? JSON.parse(raw) : {};
expect(stored["+1999"]?.sessionId).toBe(sessionId);
expect(stored["+1999"]?.updatedAt).toBeDefined();
});
it("sends overrideBody directly and skips resolver", async () => {
const sender: typeof sendMessageWeb = vi
.fn()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
const resolver = vi.fn();
setLoadConfigMock({
inbound: { allowFrom: ["+1555"], reply: { mode: "command" } },
});
await runWebHeartbeatOnce({
to: "+1555",
verbose: false,
sender,
replyResolver: resolver,
overrideBody: "manual ping",
});
expect(sender).toHaveBeenCalledWith("+1555", "manual ping", {
verbose: false,
});
expect(resolver).not.toHaveBeenCalled();
});
it("dry-run overrideBody prints and skips send", async () => {
const sender: typeof sendMessageWeb = vi.fn();
const resolver = vi.fn();
setLoadConfigMock({
inbound: { allowFrom: ["+1555"], reply: { mode: "command" } },
});
await runWebHeartbeatOnce({
to: "+1555",
verbose: false,
sender,
replyResolver: resolver,
overrideBody: "dry",
dryRun: true,
});
expect(sender).not.toHaveBeenCalled();
expect(resolver).not.toHaveBeenCalled();
});
});
describe("web auto-reply", () => {
beforeEach(() => {
vi.clearAllMocks();
+626 -107
View File
@@ -1,12 +1,22 @@
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { waitForever } from "../cli/wait.js";
import { loadConfig } from "../config/config.js";
import {
DEFAULT_IDLE_MINUTES,
deriveSessionKey,
loadSessionStore,
resolveStorePath,
saveSessionStore,
} from "../config/sessions.js";
import { danger, isVerbose, logVerbose, success } from "../globals.js";
import { logInfo } from "../logger.js";
import { getChildLogger } from "../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js";
import { monitorWebInbox } from "./inbound.js";
import { loadWebMedia } from "./media.js";
import { sendMessageWeb } from "./outbound.js";
import {
computeBackoff,
newConnectionId,
@@ -18,16 +28,430 @@ import {
import { getWebAuthAgeMs } from "./session.js";
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
type WebInboundMsg = Parameters<
typeof monitorWebInbox
>[0]["onMessage"] extends (msg: infer M) => unknown
? M
: never;
export type WebMonitorTuning = {
reconnect?: Partial<ReconnectPolicy>;
heartbeatSeconds?: number;
replyHeartbeatMinutes?: number;
replyHeartbeatNow?: boolean;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
};
const formatDuration = (ms: number) =>
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const HEARTBEAT_PROMPT = "HEARTBEAT ultrathink";
export function resolveReplyHeartbeatMinutes(
cfg: ReturnType<typeof loadConfig>,
overrideMinutes?: number,
) {
const raw = overrideMinutes ?? cfg.inbound?.reply?.heartbeatMinutes;
if (raw === 0) return null;
if (typeof raw === "number" && raw > 0) return raw;
return cfg.inbound?.reply?.mode === "command"
? DEFAULT_REPLY_HEARTBEAT_MINUTES
: null;
}
export function stripHeartbeatToken(raw?: string) {
if (!raw) return { shouldSkip: true, text: "" };
const trimmed = raw.trim();
if (!trimmed) return { shouldSkip: true, text: "" };
if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" };
const withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim();
return {
shouldSkip: withoutToken.length === 0,
text: withoutToken || trimmed,
};
}
export async function runWebHeartbeatOnce(opts: {
cfg?: ReturnType<typeof loadConfig>;
to: string;
verbose?: boolean;
replyResolver?: typeof getReplyFromConfig;
runtime?: RuntimeEnv;
sender?: typeof sendMessageWeb;
sessionId?: string;
overrideBody?: string;
dryRun?: boolean;
}) {
const {
cfg: cfgOverride,
to,
verbose = false,
sessionId,
overrideBody,
dryRun = false,
} = opts;
const _runtime = opts.runtime ?? defaultRuntime;
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
const sender = opts.sender ?? sendMessageWeb;
const runId = newConnectionId();
const heartbeatLogger = getChildLogger({
module: "web-heartbeat",
runId,
to,
});
const cfg = cfgOverride ?? loadConfig();
if (sessionId) {
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
const store = loadSessionStore(storePath);
store[to] = {
...(store[to] ?? {}),
sessionId,
updatedAt: Date.now(),
};
await saveSessionStore(storePath, store);
}
const sessionSnapshot = getSessionSnapshot(cfg, to, true);
if (verbose) {
heartbeatLogger.info(
{
to,
sessionKey: sessionSnapshot.key,
sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null,
sessionFresh: sessionSnapshot.fresh,
idleMinutes: sessionSnapshot.idleMinutes,
},
"heartbeat session snapshot",
);
}
if (overrideBody && overrideBody.trim().length === 0) {
throw new Error("Override body must be non-empty when provided.");
}
try {
if (overrideBody) {
if (dryRun) {
console.log(
success(
`[dry-run] web send -> ${to}: ${overrideBody.trim()} (manual message)`,
),
);
return;
}
const sendResult = await sender(to, overrideBody, { verbose });
heartbeatLogger.info(
{
to,
messageId: sendResult.messageId,
chars: overrideBody.length,
reason: "manual-message",
},
"manual heartbeat message sent",
);
console.log(
success(
`sent manual message to ${to} (web), id ${sendResult.messageId}`,
),
);
return;
}
const replyResult = await replyResolver(
{
Body: HEARTBEAT_PROMPT,
From: to,
To: to,
MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId,
},
undefined,
cfg,
);
if (
!replyResult ||
(!replyResult.text &&
!replyResult.mediaUrl &&
!replyResult.mediaUrls?.length)
) {
heartbeatLogger.info(
{
to,
reason: "empty-reply",
sessionId: sessionSnapshot.entry?.sessionId ?? null,
},
"heartbeat skipped",
);
if (verbose) console.log(success("heartbeat: ok (empty reply)"));
return;
}
const hasMedia = Boolean(
replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0,
);
const stripped = stripHeartbeatToken(replyResult.text);
if (stripped.shouldSkip && !hasMedia) {
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
const sessionCfg = cfg.inbound?.reply?.session;
const storePath = resolveStorePath(sessionCfg?.store);
const store = loadSessionStore(storePath);
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
await saveSessionStore(storePath, store);
}
heartbeatLogger.info(
{ to, reason: "heartbeat-token", rawLength: replyResult.text?.length },
"heartbeat skipped",
);
console.log(success("heartbeat: ok (HEARTBEAT_OK)"));
return;
}
if (hasMedia) {
heartbeatLogger.warn(
{ to },
"heartbeat reply contained media; sending text only",
);
}
const finalText = stripped.text || replyResult.text || "";
if (dryRun) {
heartbeatLogger.info(
{ to, reason: "dry-run", chars: finalText.length },
"heartbeat dry-run",
);
console.log(
success(`[dry-run] heartbeat -> ${to}: ${finalText.slice(0, 200)}`),
);
return;
}
const sendResult = await sender(to, finalText, { verbose });
heartbeatLogger.info(
{ to, messageId: sendResult.messageId, chars: finalText.length },
"heartbeat sent",
);
console.log(success(`heartbeat: alert sent to ${to}`));
} catch (err) {
heartbeatLogger.warn({ to, error: String(err) }, "heartbeat failed");
console.log(danger(`heartbeat: failed - ${String(err)}`));
throw err;
}
}
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
const sessionCfg = cfg.inbound?.reply?.session;
const storePath = resolveStorePath(sessionCfg?.store);
const store = loadSessionStore(storePath);
const candidates = Object.entries(store).filter(([key]) => key !== "global");
if (candidates.length === 0) {
const allowFrom =
Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0
? cfg.inbound.allowFrom.filter((v) => v !== "*")
: [];
if (allowFrom.length === 0) return null;
return allowFrom[0] ? normalizeE164(allowFrom[0]) : null;
}
const mostRecent = candidates.sort(
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
)[0];
return mostRecent ? normalizeE164(mostRecent[0]) : null;
}
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
const sessionCfg = cfg.inbound?.reply?.session;
const scope = sessionCfg?.scope ?? "per-sender";
if (scope === "global") return [];
const storePath = resolveStorePath(sessionCfg?.store);
const store = loadSessionStore(storePath);
return Object.entries(store)
.filter(([key]) => key !== "global" && key !== "unknown")
.map(([key, entry]) => ({
to: normalizeE164(key),
updatedAt: entry?.updatedAt ?? 0,
}))
.filter(({ to }) => Boolean(to))
.sort((a, b) => b.updatedAt - a.updatedAt);
}
export function resolveHeartbeatRecipients(
cfg: ReturnType<typeof loadConfig>,
opts: { to?: string; all?: boolean } = {},
) {
if (opts.to) return { recipients: [normalizeE164(opts.to)], source: "flag" };
const sessionRecipients = getSessionRecipients(cfg);
const allowFrom =
Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0
? cfg.inbound.allowFrom.filter((v) => v !== "*").map(normalizeE164)
: [];
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
if (opts.all) {
const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]);
return { recipients: all, source: "all" as const };
}
if (sessionRecipients.length === 1) {
return { recipients: [sessionRecipients[0].to], source: "session-single" };
}
if (sessionRecipients.length > 1) {
return {
recipients: sessionRecipients.map((s) => s.to),
source: "session-ambiguous" as const,
};
}
return { recipients: allowFrom, source: "allowFrom" as const };
}
function getSessionSnapshot(
cfg: ReturnType<typeof loadConfig>,
from: string,
isHeartbeat = false,
) {
const sessionCfg = cfg.inbound?.reply?.session;
const scope = sessionCfg?.scope ?? "per-sender";
const key = deriveSessionKey(scope, { From: from, To: "", Body: "" });
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
const entry = store[key];
const idleMinutes = Math.max(
(isHeartbeat
? (sessionCfg?.heartbeatIdleMinutes ?? sessionCfg?.idleMinutes)
: sessionCfg?.idleMinutes) ?? DEFAULT_IDLE_MINUTES,
1,
);
const fresh = !!(
entry && Date.now() - entry.updatedAt <= idleMinutes * 60_000
);
return { key, entry, fresh, idleMinutes };
}
async function deliverWebReply(params: {
replyResult: ReplyPayload;
msg: WebInboundMsg;
maxMediaBytes: number;
replyLogger: ReturnType<typeof getChildLogger>;
runtime: RuntimeEnv;
connectionId?: string;
skipLog?: boolean;
}) {
const {
replyResult,
msg,
maxMediaBytes,
replyLogger,
runtime,
connectionId,
skipLog,
} = params;
const replyStarted = Date.now();
const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl
? [replyResult.mediaUrl]
: [];
if (mediaList.length === 0 && replyResult.text) {
await msg.reply(replyResult.text || "");
if (!skipLog) {
logInfo(
`✅ Sent web reply to ${msg.from} (${(Date.now() - replyStarted).toFixed(0)}ms)`,
runtime,
);
}
replyLogger.info(
{
correlationId: msg.id ?? newConnectionId(),
connectionId: connectionId ?? null,
to: msg.from,
from: msg.to,
text: replyResult.text,
mediaUrl: null,
mediaSizeBytes: null,
mediaKind: null,
durationMs: Date.now() - replyStarted,
},
"auto-reply sent (text)",
);
return;
}
const cleanText = replyResult.text ?? undefined;
for (const [index, mediaUrl] of mediaList.entries()) {
try {
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
if (isVerbose()) {
logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
);
logVerbose(
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
);
}
const caption = index === 0 ? cleanText || undefined : undefined;
if (media.kind === "image") {
await msg.sendMedia({
image: media.buffer,
caption,
mimetype: media.contentType,
});
} else if (media.kind === "audio") {
await msg.sendMedia({
audio: media.buffer,
ptt: true,
mimetype: media.contentType,
caption,
});
} else if (media.kind === "video") {
await msg.sendMedia({
video: media.buffer,
caption,
mimetype: media.contentType,
});
} else {
const fileName = mediaUrl.split("/").pop() ?? "file";
const mimetype = media.contentType ?? "application/octet-stream";
await msg.sendMedia({
document: media.buffer,
fileName,
caption,
mimetype,
});
}
logInfo(
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
runtime,
);
replyLogger.info(
{
correlationId: msg.id ?? newConnectionId(),
connectionId: connectionId ?? null,
to: msg.from,
from: msg.to,
text: index === 0 ? (cleanText ?? null) : null,
mediaUrl,
mediaSizeBytes: media.buffer.length,
mediaKind: media.kind,
durationMs: Date.now() - replyStarted,
},
"auto-reply sent (media)",
);
} catch (err) {
console.error(
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
);
if (index === 0 && cleanText) {
console.log(`⚠️ Media skipped; sent text-only to ${msg.from}`);
await msg.reply(cleanText || "");
}
}
}
}
export async function monitorWebProvider(
verbose: boolean,
listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox,
@@ -51,6 +475,10 @@ export async function monitorWebProvider(
cfg,
tuning.heartbeatSeconds,
);
const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes(
cfg,
tuning.replyHeartbeatMinutes,
);
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const sleep =
tuning.sleep ??
@@ -79,8 +507,10 @@ export async function monitorWebProvider(
const connectionId = newConnectionId();
const startedAt = Date.now();
let heartbeat: NodeJS.Timeout | null = null;
let replyHeartbeatTimer: NodeJS.Timeout | null = null;
let lastMessageAt: number | null = null;
let handledMessages = 0;
let lastInboundMsg: WebInboundMsg | null = null;
const listener = await (listenerFactory ?? monitorWebInbox)({
verbose,
@@ -106,7 +536,8 @@ export async function monitorWebProvider(
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
const replyStarted = Date.now();
lastInboundMsg = msg;
const replyResult = await (replyResolver ?? getReplyFromConfig)(
{
Body: msg.body,
@@ -133,122 +564,27 @@ export async function monitorWebProvider(
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;
await deliverWebReply({
replyResult,
msg,
maxMediaBytes,
replyLogger,
runtime,
connectionId,
});
if (isVerbose()) {
console.log(
success(
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${hasMedia ? ", media" : ""}, ${formatDuration(durationMs)})`,
`↩️ Auto-replied to ${msg.from} (web${replyResult.mediaUrl || replyResult.mediaUrls?.length ? ", media" : ""})`,
),
);
} else {
console.log(
success(
`↩️ ${replyResult.text ?? "<media>"}${hasMedia ? " (media)" : ""}`,
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl || replyResult.mediaUrls?.length ? " (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(
@@ -261,6 +597,7 @@ export async function monitorWebProvider(
const closeListener = async () => {
if (heartbeat) clearInterval(heartbeat);
if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer);
try {
await listener.close();
} catch (err) {
@@ -285,6 +622,182 @@ export async function monitorWebProvider(
}, heartbeatSeconds * 1000);
}
const runReplyHeartbeat = async () => {
if (!replyHeartbeatMinutes) return;
const tickStart = Date.now();
if (!lastInboundMsg) {
const fallbackTo = getFallbackRecipient(cfg);
if (!fallbackTo) {
heartbeatLogger.info(
{
connectionId,
reason: "no-recent-inbound",
durationMs: Date.now() - tickStart,
},
"reply heartbeat skipped",
);
console.log(success("heartbeat: skipped (no recent inbound)"));
return;
}
const snapshot = getSessionSnapshot(cfg, fallbackTo, true);
if (!snapshot.entry) {
heartbeatLogger.info(
{ connectionId, to: fallbackTo, reason: "no-session-for-fallback" },
"reply heartbeat skipped",
);
console.log(success("heartbeat: skipped (no session to resume)"));
return;
}
if (isVerbose()) {
heartbeatLogger.info(
{
connectionId,
to: fallbackTo,
reason: "fallback-session",
sessionId: snapshot.entry?.sessionId ?? null,
sessionFresh: snapshot.fresh,
},
"reply heartbeat start",
);
}
await runWebHeartbeatOnce({
cfg,
to: fallbackTo,
verbose,
replyResolver,
runtime,
sessionId: snapshot.entry.sessionId,
});
heartbeatLogger.info(
{
connectionId,
to: fallbackTo,
...snapshot,
durationMs: Date.now() - tickStart,
},
"reply heartbeat sent (fallback session)",
);
return;
}
try {
const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from);
if (isVerbose()) {
heartbeatLogger.info(
{
connectionId,
to: lastInboundMsg.from,
intervalMinutes: replyHeartbeatMinutes,
sessionKey: snapshot.key,
sessionId: snapshot.entry?.sessionId ?? null,
sessionFresh: snapshot.fresh,
},
"reply heartbeat start",
);
}
const replyResult = await (replyResolver ?? getReplyFromConfig)(
{
Body: HEARTBEAT_PROMPT,
From: lastInboundMsg.from,
To: lastInboundMsg.to,
MessageSid: snapshot.entry?.sessionId,
MediaPath: undefined,
MediaUrl: undefined,
MediaType: undefined,
},
{
onReplyStart: lastInboundMsg.sendComposing,
},
);
if (
!replyResult ||
(!replyResult.text &&
!replyResult.mediaUrl &&
!replyResult.mediaUrls?.length)
) {
heartbeatLogger.info(
{
connectionId,
durationMs: Date.now() - tickStart,
reason: "empty-reply",
},
"reply heartbeat skipped",
);
console.log(success("heartbeat: ok (empty reply)"));
return;
}
const stripped = stripHeartbeatToken(replyResult.text);
const hasMedia = Boolean(
replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0,
);
if (stripped.shouldSkip && !hasMedia) {
heartbeatLogger.info(
{
connectionId,
durationMs: Date.now() - tickStart,
reason: "heartbeat-token",
rawLength: replyResult.text?.length ?? 0,
},
"reply heartbeat skipped",
);
console.log(success("heartbeat: ok (HEARTBEAT_OK)"));
return;
}
const cleanedReply: ReplyPayload = {
...replyResult,
text: stripped.text,
};
await deliverWebReply({
replyResult: cleanedReply,
msg: lastInboundMsg,
maxMediaBytes,
replyLogger,
runtime,
connectionId,
});
const durationMs = Date.now() - tickStart;
const summary = `heartbeat: alert sent (${formatDuration(durationMs)})`;
console.log(summary);
heartbeatLogger.info(
{
connectionId,
durationMs,
hasMedia,
chars: stripped.text?.length ?? 0,
},
"reply heartbeat sent",
);
} catch (err) {
const durationMs = Date.now() - tickStart;
heartbeatLogger.warn(
{
connectionId,
error: String(err),
durationMs,
},
"reply heartbeat failed",
);
console.log(
danger(`heartbeat: failed (${formatDuration(durationMs)})`),
);
}
};
if (replyHeartbeatMinutes && !replyHeartbeatTimer) {
const intervalMs = replyHeartbeatMinutes * 60_000;
replyHeartbeatTimer = setInterval(() => {
void runReplyHeartbeat();
}, intervalMs);
if (tuning.replyHeartbeatNow) {
void runReplyHeartbeat();
}
}
logInfo(
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
runtime,
@@ -296,7 +809,13 @@ export async function monitorWebProvider(
}
const reason = await Promise.race([
listener.onClose ?? waitForever(),
listener.onClose?.catch((err) => {
reconnectLogger.error(
{ error: String(err) },
"listener.onClose rejected",
);
return { status: 500, isLoggedOut: false, error: err };
}) ?? waitForever(),
abortPromise ?? waitForever(),
]);
+104
View File
@@ -0,0 +1,104 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const HOME = path.join(
os.tmpdir(),
`warelay-inbound-media-${crypto.randomUUID()}`,
);
process.env.HOME = HOME;
vi.mock("@whiskeysockets/baileys", async () => {
const actual = await vi.importActual<
typeof import("@whiskeysockets/baileys")
>("@whiskeysockets/baileys");
const jpegBuffer = Buffer.from([
0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x02, 0x02,
0x02, 0x03, 0x02, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x06, 0x04,
0x04, 0x04, 0x04, 0x04, 0x08, 0x06, 0x06, 0x05, 0x06, 0x09, 0x08, 0x0a,
0x0a, 0x09, 0x08, 0x09, 0x09, 0x0a, 0x0c, 0x0f, 0x0c, 0x0a, 0x0b, 0x0e,
0x0b, 0x09, 0x09, 0x0d, 0x11, 0x0d, 0x0e, 0x0f, 0x10, 0x10, 0x11, 0x10,
0x0a, 0x0c, 0x12, 0x13, 0x12, 0x10, 0x13, 0x0f, 0x10, 0x10, 0x10, 0xff,
0xc0, 0x00, 0x11, 0x08, 0x00, 0x01, 0x00, 0x01, 0x03, 0x01, 0x11, 0x00,
0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xda,
0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00,
0xff, 0xd9,
]);
return {
...actual,
downloadMediaMessage: vi.fn().mockResolvedValue(jpegBuffer),
};
});
vi.mock("./session.js", () => {
const { EventEmitter } = require("node:events");
const ev = new EventEmitter();
const sock = {
ev,
ws: { close: vi.fn() },
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
readMessages: vi.fn().mockResolvedValue(undefined),
updateMediaMessage: vi.fn(),
logger: {},
user: { id: "me@s.whatsapp.net" },
};
return {
createWaSocket: vi.fn().mockResolvedValue(sock),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
getStatusCode: vi.fn(() => 200),
};
});
import { monitorWebInbox } from "./inbound.js";
describe("web inbound media saves with extension", () => {
beforeAll(async () => {
await fs.rm(HOME, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(HOME, { recursive: true, force: true });
});
it("stores inbound image with jpeg extension", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });
const { createWaSocket } = await import("./session.js");
const realSock = await (
createWaSocket as unknown as () => Promise<{
ev: import("node:events").EventEmitter;
}>
)();
const upsert = {
type: "notify",
messages: [
{
key: { id: "img1", fromMe: false, remoteJid: "111@s.whatsapp.net" },
message: { imageMessage: { mimetype: "image/jpeg" } },
messageTimestamp: 1_700_000_001,
},
],
};
realSock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setTimeout(resolve, 5));
expect(onMessage).toHaveBeenCalledTimes(1);
const msg = onMessage.mock.calls[0][0];
const mediaPath = msg.mediaPath;
expect(mediaPath).toBeDefined();
expect(path.extname(mediaPath as string)).toBe(".jpg");
const stat = await fs.stat(mediaPath as string);
expect(stat.size).toBeGreaterThan(0);
await listener.close();
});
});
+17 -5
View File
@@ -165,12 +165,24 @@ export async function monitorWebInbox(options: {
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
if (update.connection === "close") {
const status = getStatusCode(update.lastDisconnect?.error);
try {
if (update.connection === "close") {
const status = getStatusCode(update.lastDisconnect?.error);
onCloseResolve?.({
status,
isLoggedOut: status === DisconnectReason.loggedOut,
error: update.lastDisconnect?.error,
});
}
} catch (err) {
inboundLogger.error(
{ error: String(err) },
"connection.update handler error",
);
onCloseResolve?.({
status,
isLoggedOut: status === DisconnectReason.loggedOut,
error: update.lastDisconnect?.error,
status: undefined,
isLoggedOut: false,
error: err,
});
}
},
+16
View File
@@ -38,4 +38,20 @@ describe("web media loading", () => {
expect(result.buffer.length).toBeLessThanOrEqual(cap);
expect(result.buffer.length).toBeLessThan(buffer.length);
});
it("sniffs mime before extension when loading local files", async () => {
const pngBuffer = await sharp({
create: { width: 2, height: 2, channels: 3, background: "#00ff00" },
})
.png()
.toBuffer();
const wrongExt = path.join(os.tmpdir(), `warelay-media-${Date.now()}.bin`);
tmpFiles.push(wrongExt);
await fs.writeFile(wrongExt, pngBuffer);
const result = await loadWebMedia(wrongExt, 1024 * 1024);
expect(result.kind).toBe("image");
expect(result.contentType).toBe("image/jpeg");
});
});
+7 -20
View File
@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { isVerbose, logVerbose } from "../globals.js";
@@ -8,6 +7,7 @@ import {
maxBytesForKind,
mediaKindFromMime,
} from "../media/constants.js";
import { detectMime } from "../media/mime.js";
export async function loadWebMedia(
mediaUrl: string,
@@ -45,7 +45,11 @@ export async function loadWebMedia(
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
}
const array = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type");
const contentType = detectMime({
buffer: array,
headerMime: res.headers.get("content-type"),
filePath: mediaUrl,
});
const kind = mediaKindFromMime(contentType);
const cap = Math.min(
maxBytes ?? maxBytesForKind(kind),
@@ -66,24 +70,7 @@ export async function loadWebMedia(
// Local path
const data = await fs.readFile(mediaUrl);
const ext = path.extname(mediaUrl);
const mime =
(ext &&
(
{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".pdf": "application/pdf",
} as Record<string, string | undefined>
)[ext.toLowerCase()]) ??
undefined;
const mime = detectMime({ buffer: data, filePath: mediaUrl });
const kind = mediaKindFromMime(mime);
const cap = Math.min(
maxBytes ?? maxBytesForKind(kind),
+33 -14
View File
@@ -57,29 +57,48 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
markOnlineOnConnect: false,
});
const sessionLogger = getChildLogger({ module: "web-session" });
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"),
);
try {
const { connection, lastDisconnect, qr } = update;
if (qr && printQr) {
console.log("Scan this QR in WhatsApp (Linked Devices):");
qrcode.generate(qr, { small: true });
}
}
if (connection === "open" && verbose) {
console.log(success("WhatsApp Web connected."));
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."));
}
} catch (err) {
sessionLogger.error(
{ error: String(err) },
"connection.update handler error",
);
}
},
);
// Handle WebSocket-level errors to prevent unhandled exceptions from crashing the process
if (
sock.ws &&
typeof (sock.ws as unknown as { on?: unknown }).on === "function"
) {
sock.ws.on("error", (err: Error) => {
sessionLogger.error({ error: String(err) }, "WebSocket error");
});
}
return sock;
}