Add warelay CLI with Twilio webhook support
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -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
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
55
README.md
Normal file
55
README.md
Normal file
@@ -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 <seconds>`, `--lookback <minutes>`
|
||||||
|
- Webhook (push, works well with Tailscale): `pnpm warelay webhook --port 42873 --reply "Got it!"`
|
||||||
|
- 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)
|
||||||
|
- 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 -- <subcommand>` (e.g. `pnpm dev -- send --to +1...`).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Monitor uses polling; webhook mode is push (recommended).
|
||||||
|
- Stop monitor/webhook with `Ctrl+C`.
|
||||||
43
package.json
Normal file
43
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
511
src/index.ts
Normal file
511
src/index.ts
Normal file
@@ -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<string, unknown>)[key];
|
||||||
|
return value == null ? '' : String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReplyFromConfig(ctx: MsgContext): Promise<string | undefined> {
|
||||||
|
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<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
|
||||||
|
) {
|
||||||
|
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('<Response></Response>');
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<void>((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<typeof createClient>, 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<any>;
|
||||||
|
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<typeof createClient>,
|
||||||
|
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<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')
|
||||||
|
.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>', 'Port to listen on', '42873')
|
||||||
|
.option('--path <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);
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user