Files
clawdbot/src/index.ts
2025-11-24 11:53:26 +01:00

628 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<ExecResult> {
// 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<void> {
// 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<boolean> {
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<string, unknown>)[key];
return value == null ? '' : String(value);
});
}
async function getReplyFromConfig(ctx: MsgContext): Promise<string | undefined> {
// 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<string, unknown>;
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<string, unknown> | 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<typeof createClient>,
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('<Response></Response>');
});
return new Promise<void>((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<string, unknown>) : {};
const self = parsed?.['Self'] as Record<string, unknown> | 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<string, unknown>) : {};
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<typeof createClient>, from: string) {
// Fetch sender SID that matches configured WhatsApp from number.
const resp = await (client as unknown as { request: (options: Record<string, unknown>) => 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<string, unknown> | undefined;
const senders = Array.isArray((data as Record<string, unknown> | 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<string, unknown>).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<typeof createClient>,
senderSid: string,
url: string,
method: 'POST' | 'GET' = 'POST'
) {
// Point Twilio sender webhook at the provided URL.
await (client as unknown as { request: (options: Record<string, unknown>) => Promise<unknown> }).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<string>();
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 <number>', 'Recipient number in E.164 (e.g. +15551234567)')
.requiredOption('-m, --message <text>', 'Message body')
.option('-w, --wait <seconds>', 'Wait for delivery status (0 to skip)', '20')
.option('-p, --poll <seconds>', '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 <seconds>', 'Polling interval in seconds', '5')
.option('-l, --lookback <minutes>', '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>', 'Port to listen on', '42873')
.option('-r, --reply <text>', 'Optional auto-reply text')
.option('--path <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>', 'Port to listen on', '42873')
.option('--path <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);