diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..29652fe46 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env and fill with your Twilio credentials +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_auth_token_here +# Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp: +TWILIO_WHATSAPP_FROM=whatsapp:+17343367101 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..885509ebd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.env +dist +pnpm-lock.yaml diff --git a/README.md b/README.md new file mode 100644 index 000000000..5c43d92b9 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# 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. + +## Setup + +1. `pnpm install` +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`. +3. Build once for the runnable bin: `pnpm build` + +## Commands + +- 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. + - `--poll` seconds (default 2) sets the polling interval while waiting. +- Monitor (polling): `pnpm warelay monitor` (defaults: 5s interval, 5m lookback) + - Options: `--interval `, `--lookback ` +- Webhook (push, works well with Tailscale): `pnpm warelay webhook --port 42873 --reply "Got it!"` + - Points Twilio’s “Incoming Message” webhook to `http://: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) +- Setup helper: `pnpm warelay setup --port 42873 --path /webhook/whatsapp` + - Validates Twilio env, confirms `tailscale` binary, starts the webhook, enables Tailscale Funnel, and sets the Twilio incoming webhook to your Funnel URL. + +## Config-driven auto-replies + +Put a JSON5 config at `~/.warelay/warelay.json`. Examples: + +```json5 +{ + inbound: { + // Static text reply with templating + 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}}"'] + } + } +} +``` + +During dev you can run without building: `pnpm dev -- ` (e.g. `pnpm dev -- send --to +1...`). + +## Notes + +- Monitor uses polling; webhook mode is push (recommended). +- Stop monitor/webhook with `Ctrl+C`. diff --git a/package.json b/package.json new file mode 100644 index 000000000..a3ca9348e --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "warelay", + "version": "1.0.0", + "description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio", + "type": "module", + "main": "dist/index.js", + "bin": { + "warelay": "dist/index.js", + "warely": "dist/index.js" + }, + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "warelay": "node dist/index.js", + "warely": "node dist/index.js", + "lint": "echo \"No linter configured\"", + "test": "echo \"No tests yet\"" + }, + "keywords": [], + "author": "", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + }, + "packageManager": "pnpm@10.23.0", + "dependencies": { + "body-parser": "^2.2.0", + "commander": "^14.0.2", + "dotenv": "^17.2.3", + "express": "^5.1.0", + "json5": "^2.2.3", + "twilio": "^5.10.6" + }, + "devDependencies": { + "@types/body-parser": "^1.19.6", + "@types/express": "^5.0.5", + "@types/json5": "^2.2.0", + "@types/node": "^24.10.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..48c8aa11a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,511 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import dotenv from 'dotenv'; +import process from 'node:process'; +import Twilio from 'twilio'; +import type { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message.js'; +import express from 'express'; +import bodyParser from 'body-parser'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import JSON5 from 'json5'; + +dotenv.config(); + +const program = new Command(); + +type AuthMode = + | { accountSid: string; authToken: string } + | { accountSid: string; apiKey: string; apiSecret: string }; + +type EnvConfig = { + accountSid: string; + whatsappFrom: string; + auth: AuthMode; +}; + +function readEnv(): EnvConfig { + const accountSid = process.env.TWILIO_ACCOUNT_SID; + const whatsappFrom = process.env.TWILIO_WHATSAPP_FROM; + const authToken = process.env.TWILIO_AUTH_TOKEN; + const apiKey = process.env.TWILIO_API_KEY; + const apiSecret = process.env.TWILIO_API_SECRET; + + if (!accountSid) { + console.error('Missing env var TWILIO_ACCOUNT_SID'); + process.exit(1); + } + if (!whatsappFrom) { + console.error('Missing env var TWILIO_WHATSAPP_FROM'); + process.exit(1); + } + + let auth: AuthMode | undefined; + if (apiKey && apiSecret) { + auth = { accountSid, apiKey, apiSecret }; + } else if (authToken) { + auth = { accountSid, authToken }; + } else { + console.error('Provide either TWILIO_AUTH_TOKEN or (TWILIO_API_KEY and TWILIO_API_SECRET)'); + process.exit(1); + } + + return { + accountSid, + whatsappFrom, + auth + }; +} + +const execFileAsync = promisify(execFile); + +async function ensureBinary(name: string) { + try { + await execFileAsync('which', [name]); + } catch { + console.error(`Missing required binary: ${name}. Please install it.`); + process.exit(1); + } +} + +function withWhatsAppPrefix(number: string): string { + return number.startsWith('whatsapp:') ? number : `whatsapp:${number}`; +} + +const CONFIG_PATH = path.join(os.homedir(), '.warelay', 'warelay.json'); + +type ReplyMode = 'text' | 'command'; + +type WarelayConfig = { + inbound?: { + reply?: { + mode: ReplyMode; + text?: string; // for mode=text, can contain {{Body}} + command?: string[]; // for mode=command, argv with templates + template?: string; // prepend template string when building command/prompt + }; + }; +}; + +function loadConfig(): WarelayConfig { + try { + if (!fs.existsSync(CONFIG_PATH)) return {}; + const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); + return JSON5.parse(raw) as WarelayConfig; + } catch (err) { + console.error(`Failed to read config at ${CONFIG_PATH}`, err); + return {}; + } +} + +type MsgContext = { + Body?: string; + From?: string; + To?: string; + MessageSid?: string; +}; + +function applyTemplate(str: string, ctx: MsgContext) { + return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { + const value = (ctx as Record)[key]; + return value == null ? '' : String(value); + }); +} + +async function getReplyFromConfig(ctx: MsgContext): Promise { + const cfg = loadConfig(); + const reply = cfg.inbound?.reply; + if (!reply) return undefined; + + if (reply.mode === 'text' && reply.text) { + return applyTemplate(reply.text, ctx); + } + + if (reply.mode === 'command' && reply.command?.length) { + const argv = reply.command.map((part) => applyTemplate(part, ctx)); + const templatePrefix = reply.template ? applyTemplate(reply.template, ctx) : ''; + const finalArgv = templatePrefix ? [argv[0], templatePrefix, ...argv.slice(1)] : argv; + try { + const { stdout } = await execFileAsync(finalArgv[0], finalArgv.slice(1), { + maxBuffer: 1024 * 1024 + }); + return stdout.trim(); + } catch (err) { + console.error('Command auto-reply failed', err); + return undefined; + } + } + + return undefined; +} + +function createClient(env: EnvConfig) { + if ('authToken' in env.auth) { + return Twilio(env.accountSid, env.auth.authToken, { + accountSid: env.accountSid + }); + } + return Twilio(env.auth.apiKey, env.auth.apiSecret, { + accountSid: env.accountSid + }); +} + +async function sendMessage(to: string, body: string) { + const env = readEnv(); + const client = createClient(env); + const from = withWhatsAppPrefix(env.whatsappFrom); + const toNumber = withWhatsAppPrefix(to); + + try { + const message = await client.messages.create({ + from, + to: toNumber, + body + }); + + console.log(`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`); + return { client, sid: message.sid }; + } catch (err) { + const anyErr = err as Record; + const code = anyErr?.['code']; + const msg = anyErr?.['message']; + const more = anyErr?.['moreInfo']; + const status = anyErr?.['status']; + console.error( + `❌ Twilio send failed${code ? ` (code ${code})` : ''}${status ? ` status ${status}` : ''}: ${msg ?? err}` + ); + if (more) console.error(`More info: ${more}`); + // Some Twilio errors include response.body with more context. + const responseBody = (anyErr?.['response'] as Record | undefined)?.['body']; + if (responseBody) { + console.error('Response body:', JSON.stringify(responseBody, null, 2)); + } + process.exit(1); + } +} + +const successTerminalStatuses = new Set(['delivered', 'read']); +const failureTerminalStatuses = new Set(['failed', 'undelivered', 'canceled']); + +async function waitForFinalStatus( + client: ReturnType, + sid: string, + timeoutSeconds: number, + pollSeconds: number +) { + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + const m = await client.messages(sid).fetch(); + const status = m.status ?? 'unknown'; + if (successTerminalStatuses.has(status)) { + console.log(`✅ Delivered (status: ${status})`); + return; + } + if (failureTerminalStatuses.has(status)) { + console.error( + `❌ Delivery failed (status: ${status}${ + m.errorCode ? `, code ${m.errorCode}` : '' + })${m.errorMessage ? `: ${m.errorMessage}` : ''}` + ); + process.exit(1); + } + await sleep(pollSeconds * 1000); + } + console.log('ℹ️ Timed out waiting for final status; message may still be in flight.'); +} + +async function startWebhook( + port: number, + path = '/webhook/whatsapp', + autoReply?: string +) { + const env = readEnv(); + const app = express(); + + // Twilio sends application/x-www-form-urlencoded + app.use(bodyParser.urlencoded({ extended: false })); + + app.post(path, async (req, res) => { + const { From, To, Body, MessageSid } = req.body ?? {}; + console.log(`[INBOUND] ${From} -> ${To} (${MessageSid}): ${Body}`); + + let replyText = autoReply; + if (!replyText) { + replyText = await getReplyFromConfig({ + Body, + From, + To, + MessageSid + }); + } + + if (replyText) { + try { + const client = createClient(env); + await client.messages.create({ + from: To, + to: From, + body: replyText + }); + console.log(`↩️ Auto-replied to ${From}`); + } catch (err) { + console.error('Failed to auto-reply', err); + } + } + + // Respond 200 OK to Twilio + res.type('text/xml').send(''); + }); + + return new Promise((resolve) => { + app.listen(port, () => { + console.log(`📥 Webhook listening on http://localhost:${port}${path}`); + resolve(); + }); + }); +} + +async function getTailnetHostname() { + const { stdout } = await execFileAsync('tailscale', ['status', '--json'], { maxBuffer: 2_000_000 }); + const parsed = JSON.parse(stdout); + const dns = parsed?.Self?.DNSName as string | undefined; + const ips = parsed?.Self?.TailscaleIPs as string[] | undefined; + if (dns && dns.length > 0) return dns.replace(/\.$/, ''); + if (ips && ips.length > 0) return ips[0]; + throw new Error('Could not determine Tailscale DNS or IP'); +} + +async function ensureFunnel(port: number) { + try { + const { stdout } = await execFileAsync('tailscale', ['funnel', `${port}`, `127.0.0.1:${port}`], { + maxBuffer: 200_000 + }); + console.log(stdout.trim()); + } catch (err) { + console.error('Failed to enable Tailscale Funnel. Is it allowed on your tailnet?', err); + process.exit(1); + } +} + +async function findWhatsappSenderSid(client: ReturnType, from: string) { + const resp = await (client as any).request({ + method: 'get', + uri: 'https://messaging.twilio.com/v2/Channels/Senders', + qs: { Channel: 'whatsapp', PageSize: 50 } + }); + const senders = resp?.data?.senders as Array; + if (!Array.isArray(senders)) { + throw new Error('Unable to list WhatsApp senders'); + } + const match = senders.find((s) => s.sender_id === withWhatsAppPrefix(from)); + if (!match) { + throw new Error(`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`); + } + return match.sid as string; +} + +async function updateWebhook( + client: ReturnType, + senderSid: string, + url: string, + method: 'POST' | 'GET' = 'POST' +) { + await (client as any).request({ + method: 'post', + uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, + form: { + CallbackUrl: url, + CallbackMethod: method + } + }); + console.log(`✅ Twilio webhook set to ${url}`); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function monitor(intervalSeconds: number, lookbackMinutes: number) { + const env = readEnv(); + const client = createClient(env); + const from = withWhatsAppPrefix(env.whatsappFrom); + + let since = new Date(Date.now() - lookbackMinutes * 60_000); + const seen = new Set(); + + console.log( + `📡 Monitoring inbound messages to ${from} (poll ${intervalSeconds}s, lookback ${lookbackMinutes}m)` + ); + + const updateSince = (date?: Date | null) => { + if (!date) return; + if (date.getTime() > since.getTime()) { + since = date; + } + }; + + let keepRunning = true; + process.on('SIGINT', () => { + keepRunning = false; + console.log('\n👋 Stopping monitor'); + }); + + while (keepRunning) { + try { + const messages = await client.messages.list({ + to: from, + dateSentAfter: since, + limit: 50 + }); + + messages + .filter((m: MessageInstance) => m.direction === 'inbound') + .sort((a: MessageInstance, b: MessageInstance) => { + const da = a.dateCreated?.getTime() ?? 0; + const db = b.dateCreated?.getTime() ?? 0; + return da - db; + }) + .forEach((m: MessageInstance) => { + if (seen.has(m.sid)) return; + seen.add(m.sid); + const time = m.dateCreated?.toISOString() ?? 'unknown time'; + const fromNum = m.from ?? 'unknown sender'; + console.log(`\n[${time}] ${fromNum} -> ${m.to}: ${m.body ?? ''}`); + updateSince(m.dateCreated); + }); + } catch (err) { + console.error('Error while polling messages', err); + } + + await sleep(intervalSeconds * 1000); + } +} + +program.name('warelay').description('WhatsApp relay CLI using Twilio').version('1.0.0'); + +program + .command('send') + .description('Send a WhatsApp message') + .requiredOption('-t, --to ', 'Recipient number in E.164 (e.g. +15551234567)') + .requiredOption('-m, --message ', 'Message body') + .option('-w, --wait ', 'Wait for delivery status (0 to skip)', '20') + .option('-p, --poll ', 'Polling interval while waiting', '2') + .addHelpText( + 'after', + ` +Examples: + warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default) + warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget + warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3` + ) + .action(async (opts) => { + const waitSeconds = Number.parseInt(opts.wait, 10); + const pollSeconds = Number.parseInt(opts.poll, 10); + + if (Number.isNaN(waitSeconds) || waitSeconds < 0) { + console.error('Wait must be >= 0 seconds'); + process.exit(1); + } + if (Number.isNaN(pollSeconds) || pollSeconds <= 0) { + console.error('Poll must be > 0 seconds'); + process.exit(1); + } + + const result = await sendMessage(opts.to, opts.message); + if (!result) return; + if (waitSeconds === 0) return; + await waitForFinalStatus(result.client, result.sid, waitSeconds, pollSeconds); + }); + +program + .command('monitor') + .description('Poll Twilio for inbound WhatsApp messages') + .option('-i, --interval ', 'Polling interval in seconds', '5') + .option('-l, --lookback ', 'Initial lookback window in minutes', '5') + .addHelpText( + 'after', + ` +Examples: + warelay monitor # poll every 5s, look back 5 minutes + warelay monitor --interval 2 --lookback 30` + ) + .action(async (opts) => { + const intervalSeconds = Number.parseInt(opts.interval, 10); + const lookbackMinutes = Number.parseInt(opts.lookback, 10); + + if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) { + console.error('Interval must be a positive integer'); + process.exit(1); + } + if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) { + console.error('Lookback must be >= 0 minutes'); + process.exit(1); + } + + await monitor(intervalSeconds, lookbackMinutes); + }); + +program + .command('webhook') + .description('Run a local webhook server for inbound WhatsApp (works with Tailscale/port forward)') + .option('-p, --port ', 'Port to listen on', '42873') + .option('-r, --reply ', 'Optional auto-reply text') + .option('--path ', 'Webhook path', '/webhook/whatsapp') + .addHelpText( + 'after', + ` +Examples: + warelay webhook # listen on 42873 + warelay webhook --port 45000 # pick a high, less-colliding port + warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file + +With Tailscale: + tailscale serve tcp 42873 127.0.0.1:42873 + (then set Twilio webhook URL to your tailnet IP:42873/webhook/whatsapp)` + ) + .action(async (opts) => { + const port = Number.parseInt(opts.port, 10); + if (Number.isNaN(port) || port <= 0 || port >= 65536) { + console.error('Port must be between 1 and 65535'); + process.exit(1); + } + await startWebhook(port, opts.path, opts.reply); + }); + +program + .command('setup') + .description('Auto-setup webhook + Tailscale Funnel + Twilio callback with sensible defaults') + .option('-p, --port ', 'Port to listen on', '42873') + .option('--path ', 'Webhook path', '/webhook/whatsapp') + .action(async (opts) => { + const port = Number.parseInt(opts.port, 10); + if (Number.isNaN(port) || port <= 0 || port >= 65536) { + console.error('Port must be between 1 and 65535'); + process.exit(1); + } + + // Validate env and binaries + const env = readEnv(); + await ensureBinary('tailscale'); + + // Start webhook locally + await startWebhook(port, opts.path, undefined); + + // Enable Funnel and derive public URL + await ensureFunnel(port); + const host = await getTailnetHostname(); + const publicUrl = `https://${host}${opts.path}`; + console.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`); + + // Configure Twilio sender webhook + const client = createClient(env); + const senderSid = await findWhatsappSenderSid(client, env.whatsappFrom); + await updateWebhook(client, senderSid, publicUrl, 'POST'); + + console.log('\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.'); + }); + +program.parseAsync(process.argv); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..276dba974 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmitOnError": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}