#!/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, { type Request, type Response } 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'; import readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; dotenv.config(); const program = new Command(); let globalVerbose = false; function setVerbose(v: boolean) { globalVerbose = v; } function logVerbose(message: string) { if (globalVerbose) console.log(message); } type AuthMode = | { accountSid: string; authToken: string } | { accountSid: string; apiKey: string; apiSecret: string }; type GlobalOptions = { verbose: boolean; }; type EnvConfig = { accountSid: string; whatsappFrom: string; auth: AuthMode; }; function readEnv(): EnvConfig { // Load and validate Twilio auth + sender configuration from env. 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); type ExecResult = { stdout: string; stderr: string }; async function runExec(command: string, args: string[], maxBuffer = 2_000_000): Promise { // Thin wrapper around execFile with utf8 output. if (globalVerbose) { console.log(`$ ${command} ${args.join(' ')}`); } const { stdout, stderr } = await execFileAsync(command, args, { maxBuffer, encoding: 'utf8' }); return { stdout, stderr }; } async function ensureBinary(name: string): Promise { // Abort early if a required CLI tool is missing. await runExec('which', [name]).catch(() => { console.error(`Missing required binary: ${name}. Please install it.`); process.exit(1); }); } async function promptYesNo(question: string, defaultYes = false): Promise { const rl = readline.createInterface({ input, output }); const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] '; const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase(); rl.close(); if (!answer) return defaultYes; return answer.startsWith('y'); } function withWhatsAppPrefix(number: string): string { // Ensure number has whatsapp: prefix expected by Twilio. 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 { // Read ~/.warelay/warelay.json (JSON5) if present. try { if (!fs.existsSync(CONFIG_PATH)) return {}; const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); const parsed = JSON5.parse(raw); if (typeof parsed !== 'object' || parsed === null) return {}; return parsed 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) { // Simple {{Placeholder}} interpolation using inbound message context. 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 { // Choose reply from config: static text or external command stdout. 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) { // Twilio client using either auth token or API key/secret. if ('authToken' in env.auth) { return Twilio(env.accountSid, env.auth.authToken, { accountSid: env.accountSid }); } return Twilio(env.auth.apiKey, env.auth.apiSecret, { accountSid: env.accountSid }); } async function sendMessage(to: string, body: string) { // Send outbound WhatsApp message; exit non-zero on API failure. 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 ) { // Poll message status until delivered/failed or timeout. 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 | undefined, verbose: boolean ) { // Start Express webhook; generate replies via config or CLI flag. const env = readEnv(); const app = express(); // Twilio sends application/x-www-form-urlencoded app.use(bodyParser.urlencoded({ extended: false })); app.post(path, async (req: Request, res: Response) => { const { From, To, Body, MessageSid } = req.body ?? {}; if (verbose) { 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 }); if (verbose) { 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() { // Derive tailnet hostname (or IP fallback) from tailscale status JSON. const { stdout } = await runExec('tailscale', ['status', '--json']); const parsed = stdout ? (JSON.parse(stdout) as Record) : {}; const self = parsed?.['Self'] as Record | undefined; const dns = typeof self?.['DNSName'] === 'string' ? (self['DNSName'] as string) : undefined; const ips = Array.isArray(self?.['TailscaleIPs']) ? (self?.['TailscaleIPs'] as string[]) : []; if (dns && dns.length > 0) return dns.replace(/\.$/, ''); if (ips.length > 0) return ips[0]; throw new Error('Could not determine Tailscale DNS or IP'); } async function ensureGoInstalled() { // Ensure Go toolchain is present; offer Homebrew install if missing. const hasGo = await runExec('go', ['version']).then( () => true, () => false ); if (hasGo) return; const install = await promptYesNo('Go is not installed. Install via Homebrew (brew install go)?', true); if (!install) { console.error('Go is required to build tailscaled from source. Aborting.'); process.exit(1); } logVerbose('Installing Go via Homebrew…'); await runExec('brew', ['install', 'go']); } async function ensureTailscaledInstalled() { // Ensure tailscaled binary exists; install via Homebrew tailscale if missing. const hasTailscaled = await runExec('tailscaled', ['--version']).then( () => true, () => false ); if (hasTailscaled) return; const install = await promptYesNo('tailscaled not found. Install via Homebrew (tailscale package)?', true); if (!install) { console.error('tailscaled is required for user-space funnel. Aborting.'); process.exit(1); } logVerbose('Installing tailscaled via Homebrew…'); await runExec('brew', ['install', 'tailscale']); } async function ensureFunnel(port: number) { // Ensure Funnel is enabled and publish the webhook port. try { const statusOut = (await runExec('tailscale', ['funnel', 'status', '--json'])).stdout.trim(); const parsed = statusOut ? (JSON.parse(statusOut) as Record) : {}; if (!parsed || Object.keys(parsed).length === 0) { console.error('Tailscale Funnel is not enabled on this tailnet/device.'); console.error('Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)'); console.error('macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS'); const proceed = await promptYesNo('Attempt local setup with user-space tailscaled?', true); if (!proceed) process.exit(1); await ensureGoInstalled(); await ensureTailscaledInstalled(); } logVerbose(`Enabling funnel on port ${port}…`); const { stdout } = await runExec('tailscale', ['funnel', '--yes', '--bg', `${port}`], 200_000); if (stdout.trim()) 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) { // Fetch sender SID that matches configured WhatsApp from number. const resp = await (client as unknown as { request: (options: Record) => Promise<{ data?: unknown }> }).request({ method: 'get', uri: 'https://messaging.twilio.com/v2/Channels/Senders', qs: { Channel: 'whatsapp', PageSize: 50 } }); const data = resp?.data as Record | undefined; const senders = Array.isArray((data as Record | undefined)?.senders) ? (data as { senders: unknown[] }).senders : undefined; if (!senders) { throw new Error('Unable to list WhatsApp senders'); } const match = senders.find( (s) => typeof s === 'object' && s !== null && (s as Record).sender_id === withWhatsAppPrefix(from) ) as { sid?: string } | undefined; if (!match || typeof match.sid !== 'string') { throw new Error(`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`); } return match.sid; } async function updateWebhook( client: ReturnType, senderSid: string, url: string, method: 'POST' | 'GET' = 'POST' ) { // Point Twilio sender webhook at the provided URL. await (client as unknown as { request: (options: Record) => Promise }).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) { // Promise-based sleep utility. return new Promise((resolve) => setTimeout(resolve, ms)); } async function monitor(intervalSeconds: number, lookbackMinutes: number) { // Poll Twilio for inbound messages and stream them with de-dupe. 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') .option('--verbose', 'Log inbound and auto-replies', false) .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) => { setVerbose(Boolean(opts.verbose)); 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, Boolean(opts.verbose)); }); 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') .option('--verbose', 'Verbose logging during setup/webhook', false) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); 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, Boolean(opts.verbose)); // 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);