Add WhatsApp Web provider option and docs
parent
12a3c11c6d
commit
3c8a105165
136
README.md
136
README.md
|
|
@ -1,86 +1,52 @@
|
||||||
# 📡 Warelay — WhatsApp Relay CLI (Twilio)
|
# 📡 Warelay — WhatsApp Relay CLI (Twilio)
|
||||||
|
|
||||||
Small TypeScript CLI to send, monitor, and webhook WhatsApp messages via Twilio. Supports Tailscale Funnel and config-driven auto-replies.
|
Small TypeScript CLI to send, receive, auto-reply, and inspect WhatsApp messages via Twilio. Works in polling mode or webhook mode (with Tailscale Funnel helper).
|
||||||
|
|
||||||
## Setup
|
You can also use a personal WhatsApp Web session (QR login) via `--provider web` for direct sends alongside the Twilio flow.
|
||||||
|
|
||||||
1. `pnpm install`
|
## Quick Start
|
||||||
2. Copy `.env.example` to `.env` and fill in `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_WHATSAPP_FROM` (use your approved WhatsApp-enabled Twilio number, prefixed with `whatsapp:`).
|
|
||||||
- Alternatively, use API keys: `TWILIO_API_KEY` + `TWILIO_API_SECRET` instead of `TWILIO_AUTH_TOKEN`.
|
|
||||||
- Optional: `TWILIO_SENDER_SID` to skip auto-discovery of the WhatsApp sender in Twilio.
|
|
||||||
3. (Optional) Build: `pnpm build` (scripts run directly via tsx, no build required for local use)
|
|
||||||
|
|
||||||
## Commands
|
1) Install: `pnpm install`
|
||||||
|
2) Configure `.env` (see `.env.example`): set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` (or `TWILIO_API_KEY`/`TWILIO_API_SECRET`), and `TWILIO_WHATSAPP_FROM=whatsapp:+15551234567`. Optional: `TWILIO_SENDER_SID` if you don’t want auto-discovery.
|
||||||
|
3) Send a test: `pnpm warelay send --to +15551234567 --message "Hi from warelay"`
|
||||||
|
4) Run auto-replies in polling mode (no public URL needed):
|
||||||
|
`pnpm warelay poll --interval 5 --lookback 10 --verbose`
|
||||||
|
5) Prefer webhooks? Launch everything in one step (webhook + Tailscale Funnel + Twilio callback):
|
||||||
|
`pnpm warelay up --port 42873 --path /webhook/whatsapp --verbose`
|
||||||
|
|
||||||
|
## Modes at a Glance
|
||||||
|
|
||||||
|
- **Polling (`monitor` / `poll`)**: Periodically fetch inbound messages to your WhatsApp number. Easiest to start; no ingress needed. Auto-replies still run.
|
||||||
|
- **Webhook (`webhook` / `up`)**: Push delivery from Twilio. `webhook` runs the server locally; `up` also enables Tailscale Funnel and points the Twilio sender/webhook to your public Funnel URL (with fallbacks to phone number and messaging service).
|
||||||
|
|
||||||
|
## Providers (choose per command)
|
||||||
|
|
||||||
|
- **Twilio (default)** — full feature set: send, wait/poll delivery, status, inbound polling/webhook, auto-replies. Requires `.env` Twilio creds and a WhatsApp-enabled number (`TWILIO_WHATSAPP_FROM`).
|
||||||
|
- **Web (`--provider web`)** — uses your personal WhatsApp Web session via QR. Currently **send-only** (no inbound/auto-reply/status yet) and returns immediately without delivery polling. Setup: `pnpm warelay web:login` then send with `--provider web`. Session data lives in `~/.warelay/waweb/`; if logged out, rerun `web:login`. Use at your own risk (personal-account automation can be rate-limited or logged out by WhatsApp).
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
- Send: `pnpm warelay send --to +15551234567 --message "Hello" --wait 20 --poll 2`
|
- Send: `pnpm warelay send --to +15551234567 --message "Hello" --wait 20 --poll 2`
|
||||||
- `--wait` seconds (default 20) waits for a terminal delivery status; exits non-zero on failed/undelivered/canceled.
|
- Send via personal WhatsApp Web: first `pnpm warelay web:login` (scan QR), then `pnpm warelay send --provider web --to +15551234567 --message "Hi"`
|
||||||
- `--poll` seconds (default 2) sets the polling interval while waiting.
|
- Poll (lightweight): `pnpm warelay poll --interval 5 --lookback 10 --verbose`
|
||||||
- Monitor (polling): `pnpm warelay monitor` (defaults: 5s interval, 5m lookback)
|
- Webhook only: `pnpm warelay webhook --port 42873 --path /webhook/whatsapp --verbose`
|
||||||
- Options: `--interval <seconds>`, `--lookback <minutes>`
|
- Webhook + Funnel + Twilio update: `pnpm warelay up --port 42873 --path /webhook/whatsapp --verbose`
|
||||||
- Webhook (push, works well with Tailscale): `pnpm warelay webhook --port 42873 --reply "Got it!"`
|
- Status (recent sent/received): `pnpm warelay status --limit 20 --lookback 240` (add `--json` for machine-readable)
|
||||||
- Points Twilio’s “Incoming Message” webhook to `http://<your-host>:42873/webhook/whatsapp`
|
|
||||||
- With Tailscale, expose it: `tailscale serve tcp 42873 127.0.0.1:42873` and use your tailnet IP.
|
|
||||||
- Customize path if desired: `--path /hooks/wa`
|
|
||||||
- If no `--reply`, auto-reply can be configured via `~/.warelay/warelay.json` (JSON5)
|
|
||||||
- Webhook/funnel “up”: `pnpm warelay up --port 42873 --path /webhook/whatsapp`
|
|
||||||
- Validates Twilio env, confirms `tailscale` binary, enables Tailscale Funnel, starts the webhook, and sets the Twilio incoming webhook to your Funnel URL via the Twilio API (Channels/Senders → fallback to phone number → fallback to messaging service).
|
|
||||||
- Requires Tailscale Funnel to be enabled for your tailnet/device (admin setting). If it isn’t enabled, the command will exit with instructions; alternatively expose the webhook via your own tunnel and set the Twilio URL manually.
|
|
||||||
- Polling mode (no webhooks/funnel): `pnpm warelay poll --interval 5 --lookback 10 --verbose`
|
|
||||||
- Useful fallback if Twilio webhook can’t reach you.
|
|
||||||
- Still runs config-driven auto-replies (including command-mode/Claude) for new inbound messages.
|
|
||||||
- Status: `pnpm warelay status --limit 20 --lookback 240`
|
|
||||||
- Lists recent sent/received WhatsApp messages (merged and sorted), defaulting to 20 messages from the past 4 hours. Add `--json` for machine-readable output.
|
|
||||||
|
|
||||||
## Config-driven auto-replies
|
## Auto-Reply Config (JSON5 at `~/.warelay/warelay.json`)
|
||||||
|
|
||||||
Put a JSON5 config at `~/.warelay/warelay.json`. Examples:
|
|
||||||
|
|
||||||
|
### Claude-style example (your current setup)
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
inbound: {
|
inbound: {
|
||||||
// Static text reply with templating
|
allowFrom: ["***REMOVED***"], // optional allowlist (E.164, no whatsapp: prefix)
|
||||||
reply: { mode: 'text', text: 'Echo: {{Body}}' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command-based reply (stdout becomes the reply)
|
|
||||||
{
|
|
||||||
inbound: {
|
|
||||||
reply: {
|
|
||||||
mode: 'command',
|
|
||||||
command: ['bash', '-lc', 'echo "You said: {{Body}} from {{From}}"']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Options reference (JSON5)
|
|
||||||
|
|
||||||
- `inbound.allowFrom?: string[]` — optional allowlist of E.164 numbers (no `whatsapp:` prefix). If set, only these senders trigger auto-replies.
|
|
||||||
- `inbound.reply.mode: "text" | "command"`
|
|
||||||
- `text` — send `inbound.reply.text` after templating.
|
|
||||||
- `command` — run `inbound.reply.command` (argv array) after templating; trimmed stdout becomes the reply.
|
|
||||||
- `inbound.reply.text?: string` — used when `mode` is `text`; supports `{{Body}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`.
|
|
||||||
- `inbound.reply.command?: string[]` — argv for the command to run; templated per element.
|
|
||||||
- `inbound.reply.template?: string` — optional string prepended as the second argv element (handy for adding a prompt prefix).
|
|
||||||
- `inbound.reply.bodyPrefix?: string` — optional string prepended to `Body` before templating (useful to add system instructions, e.g., `You are a helpful assistant running on the user's Mac. User writes messages via WhatsApp and you respond. You want to be concise in your responses, at most 1000 characters.\n\n`).
|
|
||||||
|
|
||||||
Example with an allowlist and Claude CLI one-shot (uses a sample number):
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
inbound: {
|
|
||||||
allowFrom: ["+15551230000"],
|
|
||||||
reply: {
|
reply: {
|
||||||
mode: "command",
|
mode: "command",
|
||||||
|
bodyPrefix: "You are a helpful assistant running on the user's Mac. User writes messages via WhatsApp and you respond. You want to be concise in your responses, at most 1000 characters.\n\n",
|
||||||
command: [
|
command: [
|
||||||
"claude",
|
"claude",
|
||||||
"--print",
|
"-p",
|
||||||
"--output-format",
|
|
||||||
"text",
|
|
||||||
"--dangerously-skip-permissions",
|
"--dangerously-skip-permissions",
|
||||||
"--system-prompt",
|
|
||||||
"You are an auto-reply bot on WhatsApp. Respond concisely.",
|
|
||||||
"{{Body}}"
|
"{{Body}}"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -88,10 +54,38 @@ Example with an allowlist and Claude CLI one-shot (uses a sample number):
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
During dev you can run without building: `pnpm dev -- <subcommand>` (e.g. `pnpm dev -- send --to +1...`). Auto-replies apply in webhook and polling modes.
|
### Simple text echo
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
inbound: {
|
||||||
|
reply: { mode: "text", text: "Echo: {{Body}}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Notes
|
Notes:
|
||||||
|
- Templates support `{{Body}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`.
|
||||||
|
- When an auto-reply starts (text or command), warelay sends a WhatsApp typing indicator tied to the inbound `MessageSid`.
|
||||||
|
|
||||||
- Monitor uses polling; webhook mode is push (recommended).
|
## Troubleshooting Delivery
|
||||||
- Stop monitor/webhook with `Ctrl+C`.
|
|
||||||
- When an auto-reply is triggered (text or command mode), warelay immediately posts a WhatsApp typing indicator tied to the inbound `MessageSid` so the user sees “typing…” while your handler runs.
|
- Auto-reply send failures now print in red with Twilio code/status and the response body (e.g., policy violation 63112). Watch terminal output when running `poll`, `webhook`, or `up`.
|
||||||
|
- Check recent messages: `pnpm warelay status --limit 20 --lookback 240`.
|
||||||
|
- If you must resend while a reply is long-running, keep messages <1600 chars (WhatsApp limit) and avoid restricted content/templates.
|
||||||
|
|
||||||
|
## Options Reference
|
||||||
|
|
||||||
|
| Field | Type / Values | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `inbound.allowFrom` | `string[]` | empty | Allowlist of E.164 numbers (no `whatsapp:`). If set, only these trigger auto-replies. |
|
||||||
|
| `inbound.reply.mode` | `"text"` \| `"command"` | — | Auto-reply type. |
|
||||||
|
| `inbound.reply.text` | `string` | — | Reply body for text mode; templated. |
|
||||||
|
| `inbound.reply.command` | `string[]` | — | Argv to run for command mode; templated per element. Stdout (trimmed) is sent. |
|
||||||
|
| `inbound.reply.template` | `string` | — | Optional string inserted as second argv element (prompt prefix). |
|
||||||
|
| `inbound.reply.bodyPrefix` | `string` | — | Prepends to `Body` before templating (ideal for system instructions). |
|
||||||
|
| `inbound.reply.timeoutSeconds` | `number` | 600 | Command timeout. |
|
||||||
|
|
||||||
|
## Dev Notes
|
||||||
|
|
||||||
|
- During dev you can run without building: `pnpm dev -- <subcommand>` (e.g., `pnpm dev -- send --to +1...`).
|
||||||
|
- Stop polling/webhook with `Ctrl+C`. CLI uses `pnpm` and `tsx`; no build required for local runs.
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,15 @@
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"twilio": "^5.10.6"
|
"qrcode-terminal": "^0.12.0",
|
||||||
|
"twilio": "^5.10.6",
|
||||||
|
"@whiskeysockets/baileys": "^6.7.7",
|
||||||
|
"pino": "^9.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.7",
|
"@biomejs/biome": "^2.3.7",
|
||||||
"@types/body-parser": "^1.19.6",
|
"@types/body-parser": "^1.19.6",
|
||||||
|
"@types/qrcode-terminal": "^0.12.2",
|
||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
|
|
|
||||||
172
src/index.ts
172
src/index.ts
|
|
@ -13,6 +13,16 @@ import { Command } from "commander";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
|
import {
|
||||||
|
DisconnectReason,
|
||||||
|
fetchLatestBaileysVersion,
|
||||||
|
makeCacheableSignalKeyStore,
|
||||||
|
makeWASocket,
|
||||||
|
useMultiFileAuthState,
|
||||||
|
} from "@whiskeysockets/baileys";
|
||||||
|
import type { ConnectionState } from "@whiskeysockets/baileys";
|
||||||
|
import pino from "pino";
|
||||||
|
import qrcode from "qrcode-terminal";
|
||||||
import Twilio from "twilio";
|
import Twilio from "twilio";
|
||||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||||
|
|
||||||
|
|
@ -366,7 +376,34 @@ function normalizePath(p: string): string {
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureDir(dir: string) {
|
||||||
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider = "twilio" | "web";
|
||||||
|
|
||||||
|
function assertProvider(input: string): asserts input is Provider {
|
||||||
|
if (input !== "twilio" && input !== "web") {
|
||||||
|
console.error("Provider must be 'twilio' or 'web'");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeE164(number: string): string {
|
||||||
|
const withoutPrefix = number.replace(/^whatsapp:/, "").trim();
|
||||||
|
const digits = withoutPrefix.replace(/[^\d+]/g, "");
|
||||||
|
if (digits.startsWith("+")) return `+${digits.slice(1)}`;
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWhatsappJid(number: string): string {
|
||||||
|
const e164 = normalizeE164(number);
|
||||||
|
const digits = e164.replace(/\D/g, "");
|
||||||
|
return `${digits}@s.whatsapp.net`;
|
||||||
|
}
|
||||||
|
|
||||||
const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
||||||
|
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "waweb");
|
||||||
const success = chalk.green;
|
const success = chalk.green;
|
||||||
const warn = chalk.yellow;
|
const warn = chalk.yellow;
|
||||||
const info = chalk.cyan;
|
const info = chalk.cyan;
|
||||||
|
|
@ -667,6 +704,110 @@ async function sendMessage(to: string, body: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createWaSocket(printQr: boolean, verbose: boolean) {
|
||||||
|
await ensureDir(WA_WEB_AUTH_DIR);
|
||||||
|
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
|
||||||
|
const { version } = await fetchLatestBaileysVersion();
|
||||||
|
const logger = pino({ level: verbose ? "info" : "silent" });
|
||||||
|
const sock = makeWASocket({
|
||||||
|
auth: {
|
||||||
|
creds: state.creds,
|
||||||
|
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||||
|
},
|
||||||
|
version,
|
||||||
|
printQRInTerminal: false,
|
||||||
|
browser: ["Warelay", "CLI", "1.0.0"],
|
||||||
|
syncFullHistory: false,
|
||||||
|
markOnlineOnConnect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
sock.ev.on("creds.update", saveCreds);
|
||||||
|
sock.ev.on("connection.update", (update: Partial<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 code = (lastDisconnect?.error as { output?: { statusCode?: number } })
|
||||||
|
?.output?.statusCode;
|
||||||
|
if (code === DisconnectReason.loggedOut) {
|
||||||
|
console.error(danger("WhatsApp session logged out. Run: warelay web:login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (connection === "open" && verbose) {
|
||||||
|
console.log(success("WhatsApp Web connected."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const handler = (update: Partial<ConnectionState>) => {
|
||||||
|
if (update.connection === "open") {
|
||||||
|
(sock.ev as unknown as { off?: Function }).off?.(
|
||||||
|
"connection.update",
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
if (update.connection === "close") {
|
||||||
|
(sock.ev as unknown as { off?: Function }).off?.(
|
||||||
|
"connection.update",
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
reject(update.lastDisconnect ?? new Error("Connection closed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sock.ev.on("connection.update", handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessageWeb(to: string, body: string) {
|
||||||
|
const sock = await createWaSocket(false, globalVerbose);
|
||||||
|
try {
|
||||||
|
await waitForWaConnection(sock);
|
||||||
|
const jid = toWhatsappJid(to);
|
||||||
|
try {
|
||||||
|
await sock.sendPresenceUpdate("composing", jid);
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Presence update skipped: ${String(err)}`);
|
||||||
|
}
|
||||||
|
const result = await sock.sendMessage(jid, { text: body });
|
||||||
|
const messageId = result?.key?.id ?? "unknown";
|
||||||
|
console.log(
|
||||||
|
success(
|
||||||
|
`✅ Sent via web session. Message ID: ${messageId} -> ${jid}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
sock.ws?.close();
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`Socket close failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWeb(verbose: boolean) {
|
||||||
|
const sock = await createWaSocket(true, verbose);
|
||||||
|
console.log(info("Waiting for WhatsApp connection..."));
|
||||||
|
try {
|
||||||
|
await waitForWaConnection(sock);
|
||||||
|
console.log(success("✅ Linked! Credentials saved for future sends."));
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
sock.ws?.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const successTerminalStatuses = new Set(["delivered", "read"]);
|
const successTerminalStatuses = new Set(["delivered", "read"]);
|
||||||
const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
|
const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
|
||||||
|
|
||||||
|
|
@ -1120,8 +1261,9 @@ async function updateWebhook(
|
||||||
"Webhook.CallbackMethod": method,
|
"Webhook.CallbackMethod": method,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fetched =
|
const fetched = await clientTyped.messaging.v2
|
||||||
await clientTyped.messaging.v2.channelsSenders(senderSid).fetch();
|
.channelsSenders(senderSid)
|
||||||
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
if (storedUrl) {
|
if (storedUrl) {
|
||||||
|
|
@ -1147,8 +1289,9 @@ async function updateWebhook(
|
||||||
callbackUrl: url,
|
callbackUrl: url,
|
||||||
callbackMethod: method,
|
callbackMethod: method,
|
||||||
});
|
});
|
||||||
const fetched =
|
const fetched = await clientTyped.messaging.v2
|
||||||
await clientTyped.messaging.v2.channelsSenders(senderSid).fetch();
|
.channelsSenders(senderSid)
|
||||||
|
.fetch();
|
||||||
const storedUrl =
|
const storedUrl =
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -1389,9 +1532,18 @@ async function listRecentMessages(
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("warelay")
|
.name("warelay")
|
||||||
.description("WhatsApp relay CLI using Twilio")
|
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||||
.version("1.0.0");
|
.version("1.0.0");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("web:login")
|
||||||
|
.description("Link your personal WhatsApp via QR (web provider)")
|
||||||
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
await loginWeb(Boolean(opts.verbose));
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("send")
|
.command("send")
|
||||||
.description("Send a WhatsApp message")
|
.description("Send a WhatsApp message")
|
||||||
|
|
@ -1402,6 +1554,7 @@ program
|
||||||
.requiredOption("-m, --message <text>", "Message body")
|
.requiredOption("-m, --message <text>", "Message body")
|
||||||
.option("-w, --wait <seconds>", "Wait for delivery status (0 to skip)", "20")
|
.option("-w, --wait <seconds>", "Wait for delivery status (0 to skip)", "20")
|
||||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
||||||
|
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
|
|
@ -1411,6 +1564,7 @@ Examples:
|
||||||
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
|
assertProvider(opts.provider);
|
||||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||||
|
|
||||||
|
|
@ -1423,6 +1577,14 @@ Examples:
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.provider === "web") {
|
||||||
|
if (waitSeconds !== 0) {
|
||||||
|
console.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||||
|
}
|
||||||
|
await sendMessageWeb(opts.to, opts.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await sendMessage(opts.to, opts.message);
|
const result = await sendMessage(opts.to, opts.message);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (waitSeconds === 0) return;
|
if (waitSeconds === 0) return;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue