Refactor CLI and Twilio modules; add helper tests and comments
parent
c71abf13a1
commit
afdaa7ef98
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parseClaudeJsonText } from "./claude.js";
|
||||||
|
|
||||||
|
describe("claude JSON parsing", () => {
|
||||||
|
it("extracts text from single JSON object", () => {
|
||||||
|
const out = parseClaudeJsonText('{"text":"hello"}');
|
||||||
|
expect(out).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts from newline-delimited JSON", () => {
|
||||||
|
const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}');
|
||||||
|
expect(out).toBe("there");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined on invalid JSON", () => {
|
||||||
|
expect(parseClaudeJsonText("not json")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Helpers specific to Claude CLI output/argv handling.
|
// Helpers specific to Claude CLI output/argv handling.
|
||||||
|
|
||||||
|
// Preferred binary name for Claude CLI invocations.
|
||||||
export const CLAUDE_BIN = "claude";
|
export const CLAUDE_BIN = "claude";
|
||||||
|
|
||||||
function extractClaudeText(payload: unknown): string | undefined {
|
function extractClaudeText(payload: unknown): string | undefined {
|
||||||
|
|
@ -45,7 +46,7 @@ function extractClaudeText(payload: unknown): string | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseClaudeJsonText(raw: string): string | undefined {
|
export function parseClaudeJsonText(raw: string): string | undefined {
|
||||||
// Handle a single JSON blob or newline-delimited JSON; return the first extracted text.
|
// Handle a single JSON blob or newline-delimited JSON; return the first extracted text.
|
||||||
const candidates = [raw, ...raw.split(/\n+/).map((s) => s.trim()).filter(Boolean)];
|
const candidates = [raw, ...raw.split(/\n+/).map((s) => s.trim()).filter(Boolean)];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export type TemplateContext = MsgContext & {
|
||||||
IsNewSession?: string;
|
IsNewSession?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||||
export function applyTemplate(str: string, ctx: TemplateContext) {
|
export function applyTemplate(str: string, ctx: TemplateContext) {
|
||||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
|
||||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||||
const value = (ctx as Record<string, unknown>)[key];
|
const value = (ctx as Record<string, unknown>)[key];
|
||||||
return value == null ? "" : String(value);
|
return value == null ? "" : String(value);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { createDefaultDeps } from "../index.js";
|
||||||
|
import { logWebSelfId, logTwilioFrom, monitorTwilio } from "../index.js";
|
||||||
|
|
||||||
|
export { createDefaultDeps, logWebSelfId, logTwilioFrom, monitorTwilio };
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { Command } from "commander";
|
||||||
|
|
||||||
|
import { defaultRuntime, setVerbose, setYes, danger, info, warn } from "../globals.js";
|
||||||
|
import { sendCommand } from "../commands/send.js";
|
||||||
|
import { statusCommand } from "../commands/status.js";
|
||||||
|
import { upCommand } from "../commands/up.js";
|
||||||
|
import { webhookCommand } from "../commands/webhook.js";
|
||||||
|
import { loginWeb, monitorWebProvider } from "../provider-web.js";
|
||||||
|
import { pickProvider } from "../provider-web.js";
|
||||||
|
import type { Provider } from "../utils.js";
|
||||||
|
import { createDefaultDeps, logWebSelfId, logTwilioFrom, monitorTwilio } from "./deps.js";
|
||||||
|
import { ensureTwilioEnv } from "../env.js";
|
||||||
|
|
||||||
|
export function buildProgram() {
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("warelay")
|
||||||
|
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||||
|
.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));
|
||||||
|
try {
|
||||||
|
await loginWeb(Boolean(opts.verbose));
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("login")
|
||||||
|
.description("Alias for web:login (personal WhatsApp Web QR link)")
|
||||||
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
try {
|
||||||
|
await loginWeb(Boolean(opts.verbose));
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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")
|
||||||
|
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||||
|
.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 deps = createDefaultDeps();
|
||||||
|
try {
|
||||||
|
await sendCommand(opts, deps, defaultRuntime);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("relay")
|
||||||
|
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
||||||
|
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||||
|
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
||||||
|
.option(
|
||||||
|
"-l, --lookback <minutes>",
|
||||||
|
"Initial lookback window for twilio mode",
|
||||||
|
"5",
|
||||||
|
)
|
||||||
|
.option("--verbose", "Verbose logging", false)
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`
|
||||||
|
Examples:
|
||||||
|
warelay relay # auto: web if logged-in, else twilio poll
|
||||||
|
warelay relay --provider web # force personal web session
|
||||||
|
warelay relay --provider twilio # force twilio poll
|
||||||
|
warelay relay --provider twilio --interval 2 --lookback 30
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
const providerPref = String(opts.provider ?? "auto");
|
||||||
|
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
||||||
|
defaultRuntime.error("--provider must be auto, web, or twilio");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
||||||
|
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||||
|
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
||||||
|
defaultRuntime.error("Interval must be a positive integer");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
||||||
|
defaultRuntime.error("Lookback must be >= 0 minutes");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||||
|
|
||||||
|
if (provider === "web") {
|
||||||
|
defaultRuntime.log(infoFmt("Provider: web (personal WhatsApp Web session)"));
|
||||||
|
logWebSelfId();
|
||||||
|
try {
|
||||||
|
await monitorWebProvider(Boolean(opts.verbose));
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (providerPref === "auto") {
|
||||||
|
defaultRuntime.error(
|
||||||
|
warn("Web session unavailable; falling back to twilio."),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTwilioEnv();
|
||||||
|
logTwilioFrom();
|
||||||
|
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("status")
|
||||||
|
.description("Show recent WhatsApp messages (sent and received)")
|
||||||
|
.option("-l, --limit <count>", "Number of messages to show", "20")
|
||||||
|
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
||||||
|
.option("--json", "Output JSON instead of text", false)
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`
|
||||||
|
Examples:
|
||||||
|
warelay status # last 20 msgs in past 4h
|
||||||
|
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
||||||
|
warelay status --json --limit 50 # machine-readable output`,
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
const deps = createDefaultDeps();
|
||||||
|
try {
|
||||||
|
await statusCommand(opts, deps, defaultRuntime);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
|
.option("-y, --yes", "Auto-confirm prompts when possible", 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)`,
|
||||||
|
)
|
||||||
|
// istanbul ignore next
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
setYes(Boolean(opts.yes));
|
||||||
|
const deps = createDefaultDeps();
|
||||||
|
try {
|
||||||
|
const server = await webhookCommand(opts, deps, defaultRuntime);
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
server.close(() => {
|
||||||
|
console.log("\n👋 Webhook stopped");
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await deps.waitForever();
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("up")
|
||||||
|
.description(
|
||||||
|
"Bring up webhook + Tailscale Funnel + Twilio callback (default webhook mode)",
|
||||||
|
)
|
||||||
|
.option("-p, --port <port>", "Port to listen on", "42873")
|
||||||
|
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
||||||
|
.option("--verbose", "Verbose logging during setup/webhook", false)
|
||||||
|
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||||
|
// istanbul ignore next
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
setYes(Boolean(opts.yes));
|
||||||
|
const deps = createDefaultDeps();
|
||||||
|
try {
|
||||||
|
const { server } = await upCommand(opts, deps, defaultRuntime);
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
server.close(() => {
|
||||||
|
console.log("\n👋 Webhook stopped");
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await deps.waitForever();
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { deriveSessionKey } from "./sessions.js";
|
||||||
|
|
||||||
|
describe("sessions", () => {
|
||||||
|
it("returns normalized per-sender key", () => {
|
||||||
|
expect(
|
||||||
|
deriveSessionKey("per-sender", { From: "whatsapp:+1555" }),
|
||||||
|
).toBe("+1555");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to unknown when sender missing", () => {
|
||||||
|
expect(deriveSessionKey("per-sender", {})).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("global scope returns global", () => {
|
||||||
|
expect(deriveSessionKey("global", { From: "+1" })).toBe("global");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -47,6 +47,7 @@ export async function saveSessionStore(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decide which session bucket to use (per-sender vs global).
|
||||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||||
if (scope === "global") return "global";
|
if (scope === "global") return "global";
|
||||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||||
|
|
|
||||||
774
src/index.ts
774
src/index.ts
|
|
@ -8,14 +8,12 @@ import process, { stdin as input, stdout as output } from "node:process";
|
||||||
import readline from "node:readline/promises";
|
import readline from "node:readline/promises";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import bodyParser from "body-parser";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { Command } from "commander";
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import express, { type Request, type Response } from "express";
|
|
||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
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";
|
||||||
|
import type { TwilioSenderListClient, TwilioRequester } from "./twilio/types.js";
|
||||||
import {
|
import {
|
||||||
runCommandWithTimeout,
|
runCommandWithTimeout,
|
||||||
runExec,
|
runExec,
|
||||||
|
|
@ -30,6 +28,19 @@ import {
|
||||||
import { readEnv, ensureTwilioEnv, type EnvConfig } from "./env.js";
|
import { readEnv, ensureTwilioEnv, type EnvConfig } from "./env.js";
|
||||||
import { createClient } from "./twilio/client.js";
|
import { createClient } from "./twilio/client.js";
|
||||||
import { logTwilioSendError, formatTwilioError } from "./twilio/utils.js";
|
import { logTwilioSendError, formatTwilioError } from "./twilio/utils.js";
|
||||||
|
import { monitorTwilio as monitorTwilioImpl } from "./twilio/monitor.js";
|
||||||
|
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
||||||
|
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
||||||
|
import {
|
||||||
|
updateWebhook as updateWebhookImpl,
|
||||||
|
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||||
|
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||||
|
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||||
|
} from "./twilio/update-webhook.js";
|
||||||
|
import {
|
||||||
|
findIncomingNumberSid as findIncomingNumberSid,
|
||||||
|
findMessagingServiceSid as findMessagingServiceSid,
|
||||||
|
} from "./twilio/update-webhook.js";
|
||||||
import { CLAUDE_BIN, parseClaudeJsonText } from "./auto-reply/claude.js";
|
import { CLAUDE_BIN, parseClaudeJsonText } from "./auto-reply/claude.js";
|
||||||
import {
|
import {
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
|
|
@ -90,7 +101,9 @@ import {
|
||||||
|
|
||||||
dotenv.config({ quiet: true });
|
dotenv.config({ quiet: true });
|
||||||
|
|
||||||
const program = new Command();
|
import { buildProgram } from "./cli/program.js";
|
||||||
|
|
||||||
|
const program = buildProgram();
|
||||||
|
|
||||||
type CliDeps = {
|
type CliDeps = {
|
||||||
sendMessage: typeof sendMessage;
|
sendMessage: typeof sendMessage;
|
||||||
|
|
@ -136,87 +149,6 @@ function createDefaultDeps(): CliDeps {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type TwilioRequestOptions = {
|
|
||||||
method: "get" | "post";
|
|
||||||
uri: string;
|
|
||||||
params?: Record<string, string | number>;
|
|
||||||
form?: Record<string, string>;
|
|
||||||
body?: unknown;
|
|
||||||
contentType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TwilioSender = { sid: string; sender_id: string };
|
|
||||||
|
|
||||||
type TwilioRequestResponse = {
|
|
||||||
data?: {
|
|
||||||
senders?: TwilioSender[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type IncomingNumber = {
|
|
||||||
sid: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
smsUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TwilioChannelsSender = {
|
|
||||||
sid?: string;
|
|
||||||
senderId?: string;
|
|
||||||
sender_id?: string;
|
|
||||||
webhook?: {
|
|
||||||
callback_url?: string;
|
|
||||||
callback_method?: string;
|
|
||||||
fallback_url?: string;
|
|
||||||
fallback_method?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type ChannelSenderUpdater = {
|
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type IncomingPhoneNumberUpdater = {
|
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type IncomingPhoneNumbersClient = {
|
|
||||||
list: (params: {
|
|
||||||
phoneNumber: string;
|
|
||||||
limit?: number;
|
|
||||||
}) => Promise<IncomingNumber[]>;
|
|
||||||
get: (sid: string) => IncomingPhoneNumberUpdater;
|
|
||||||
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
|
||||||
|
|
||||||
type TwilioSenderListClient = {
|
|
||||||
messaging: {
|
|
||||||
v2: {
|
|
||||||
channelsSenders: {
|
|
||||||
list: (params: {
|
|
||||||
channel: string;
|
|
||||||
pageSize: number;
|
|
||||||
}) => Promise<TwilioChannelsSender[]>;
|
|
||||||
(
|
|
||||||
sid: string,
|
|
||||||
): ChannelSenderUpdater & {
|
|
||||||
fetch: () => Promise<TwilioChannelsSender>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
v1: {
|
|
||||||
services: (sid: string) => {
|
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
|
||||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TwilioRequester = {
|
|
||||||
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
class PortInUseError extends Error {
|
class PortInUseError extends Error {
|
||||||
port: number;
|
port: number;
|
||||||
|
|
||||||
|
|
@ -349,91 +281,9 @@ function createClient(env: EnvConfig) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(
|
// sendMessage / waitForFinalStatus now live in src/twilio/send.ts and are imported above.
|
||||||
to: string,
|
|
||||||
body: string,
|
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
|
||||||
) {
|
|
||||||
// Send outbound WhatsApp message; exit non-zero on API failure.
|
|
||||||
const env = readEnv(runtime);
|
|
||||||
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(
|
|
||||||
success(
|
|
||||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return { client, sid: message.sid };
|
|
||||||
} catch (err) {
|
|
||||||
const anyErr = err as {
|
|
||||||
code?: string | number;
|
|
||||||
message?: unknown;
|
|
||||||
moreInfo?: unknown;
|
|
||||||
status?: string | number;
|
|
||||||
response?: { body?: unknown };
|
|
||||||
};
|
|
||||||
const { code, status } = anyErr;
|
|
||||||
const msg =
|
|
||||||
typeof anyErr?.message === "string"
|
|
||||||
? anyErr.message
|
|
||||||
: (anyErr?.message ?? err);
|
|
||||||
const more = anyErr?.moreInfo;
|
|
||||||
runtime.error(
|
|
||||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
|
||||||
);
|
|
||||||
if (more) console.error(`More info: ${more}`);
|
|
||||||
// Some Twilio errors include response.body with more context.
|
|
||||||
const responseBody = anyErr?.response?.body;
|
|
||||||
if (responseBody) {
|
|
||||||
console.error("Response body:", JSON.stringify(responseBody, null, 2));
|
|
||||||
}
|
|
||||||
runtime.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,
|
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
|
||||||
) {
|
|
||||||
// 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(success(`✅ Delivered (status: ${status})`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (failureTerminalStatuses.has(status)) {
|
|
||||||
runtime.error(
|
|
||||||
`❌ Delivery failed (status: ${status}${
|
|
||||||
m.errorCode ? `, code ${m.errorCode}` : ""
|
|
||||||
})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
|
||||||
);
|
|
||||||
runtime.exit(1);
|
|
||||||
}
|
|
||||||
await sleep(pollSeconds * 1000);
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// startWebhook now lives in src/twilio/webhook.ts; keep shim for existing imports/tests.
|
||||||
async function startWebhook(
|
async function startWebhook(
|
||||||
port: number,
|
port: number,
|
||||||
path = "/webhook/whatsapp",
|
path = "/webhook/whatsapp",
|
||||||
|
|
@ -441,91 +291,7 @@ async function startWebhook(
|
||||||
verbose: boolean,
|
verbose: boolean,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
): Promise<import("http").Server> {
|
): Promise<import("http").Server> {
|
||||||
const normalizedPath = normalizePath(path);
|
return startWebhookImpl(port, path, autoReply, verbose, runtime);
|
||||||
// Start Express webhook; generate replies via config or CLI flag.
|
|
||||||
const env = readEnv(runtime);
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// Twilio sends application/x-www-form-urlencoded
|
|
||||||
app.use(bodyParser.urlencoded({ extended: false }));
|
|
||||||
app.use((req, _res, next) => {
|
|
||||||
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post(normalizedPath, async (req: Request, res: Response) => {
|
|
||||||
const { From, To, Body, MessageSid } = req.body ?? {};
|
|
||||||
console.log(
|
|
||||||
`[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${
|
|
||||||
MessageSid ?? "no-sid"
|
|
||||||
})`,
|
|
||||||
);
|
|
||||||
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
|
||||||
|
|
||||||
const client = createClient(env);
|
|
||||||
let replyText = autoReply;
|
|
||||||
if (!replyText) {
|
|
||||||
replyText = await getReplyFromConfig(
|
|
||||||
{
|
|
||||||
Body,
|
|
||||||
From,
|
|
||||||
To,
|
|
||||||
MessageSid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onReplyStart: () => sendTypingIndicator(client, MessageSid, runtime),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replyText) {
|
|
||||||
try {
|
|
||||||
await client.messages.create({
|
|
||||||
from: To,
|
|
||||||
to: From,
|
|
||||||
body: replyText,
|
|
||||||
});
|
|
||||||
if (verbose) {
|
|
||||||
runtime.log(success(`↩️ Auto-replied to ${From}`));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logTwilioSendError(err, From ?? undefined, runtime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respond 200 OK to Twilio
|
|
||||||
res.type("text/xml").send("<Response></Response>");
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((_req, res) => {
|
|
||||||
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
|
||||||
res.status(404).send("warelay webhook: not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const server = app.listen(port);
|
|
||||||
|
|
||||||
const onListening = () => {
|
|
||||||
cleanup();
|
|
||||||
runtime.log(
|
|
||||||
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
|
||||||
);
|
|
||||||
resolve(server);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = (err: NodeJS.ErrnoException) => {
|
|
||||||
cleanup();
|
|
||||||
reject(err);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
server.off("listening", onListening);
|
|
||||||
server.off("error", onError);
|
|
||||||
};
|
|
||||||
|
|
||||||
server.once("listening", onListening);
|
|
||||||
server.once("error", onError);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForever() {
|
function waitForever() {
|
||||||
|
|
@ -742,78 +508,17 @@ async function findWhatsappSenderSid(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findIncomingNumberSid(
|
|
||||||
client: TwilioSenderListClient,
|
|
||||||
): Promise<string | null> {
|
|
||||||
// Try to locate the underlying phone number and return its SID for webhook fallback.
|
|
||||||
const env = readEnv();
|
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
|
||||||
try {
|
|
||||||
const list = await client.incomingPhoneNumbers.list({
|
|
||||||
phoneNumber: phone,
|
|
||||||
limit: 2,
|
|
||||||
});
|
|
||||||
if (!list || list.length === 0) return null;
|
|
||||||
if (list.length > 1 && isVerbose()) {
|
|
||||||
console.error(
|
|
||||||
warn("Multiple incoming numbers matched; using the first."),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return list[0]?.sid ?? null;
|
|
||||||
} catch (err) {
|
|
||||||
if (isVerbose()) console.error("incomingPhoneNumbers.list failed", err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findMessagingServiceSid(
|
|
||||||
client: TwilioSenderListClient,
|
|
||||||
): Promise<string | null> {
|
|
||||||
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
|
||||||
type IncomingNumberWithService = { messagingServiceSid?: string };
|
|
||||||
try {
|
|
||||||
const env = readEnv();
|
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
|
||||||
const list = await client.incomingPhoneNumbers.list({
|
|
||||||
phoneNumber: phone,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
const msid =
|
|
||||||
(list?.[0] as IncomingNumberWithService | undefined)
|
|
||||||
?.messagingServiceSid ?? null;
|
|
||||||
return msid;
|
|
||||||
} catch (err) {
|
|
||||||
if (isVerbose()) console.error("findMessagingServiceSid failed", err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setMessagingServiceWebhook(
|
async function setMessagingServiceWebhook(
|
||||||
client: TwilioSenderListClient,
|
client: TwilioSenderListClient,
|
||||||
url: string,
|
url: string,
|
||||||
method: "POST" | "GET",
|
method: "POST" | "GET" = "POST",
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const msid = await findMessagingServiceSid(client);
|
return setMessagingServiceWebhookImpl(client, url, method);
|
||||||
if (!msid) return false;
|
|
||||||
try {
|
|
||||||
await client.messaging.v1.services(msid).update({
|
|
||||||
InboundRequestUrl: url,
|
|
||||||
InboundRequestMethod: method,
|
|
||||||
});
|
|
||||||
const fetched = await client.messaging.v1.services(msid).fetch();
|
|
||||||
const stored = fetched?.inboundRequestUrl;
|
|
||||||
console.log(
|
|
||||||
success(
|
|
||||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
if (isVerbose()) console.error("Messaging Service update failed", err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function updateWebhook(
|
async function updateWebhook(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
senderSid: string,
|
senderSid: string,
|
||||||
|
|
@ -821,139 +526,7 @@ async function updateWebhook(
|
||||||
method: "POST" | "GET" = "POST",
|
method: "POST" | "GET" = "POST",
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
// Point Twilio sender webhook at the provided URL.
|
return updateWebhookImpl(client, senderSid, url, method, runtime);
|
||||||
const requester = client as unknown as TwilioRequester;
|
|
||||||
const clientTyped = client as unknown as TwilioSenderListClient;
|
|
||||||
|
|
||||||
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
|
||||||
try {
|
|
||||||
await requester.request({
|
|
||||||
method: "post",
|
|
||||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
|
||||||
body: {
|
|
||||||
webhook: {
|
|
||||||
callback_url: url,
|
|
||||||
callback_method: method,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
// Fetch to verify what Twilio stored
|
|
||||||
const fetched = await clientTyped.messaging.v2
|
|
||||||
.channelsSenders(senderSid)
|
|
||||||
.fetch();
|
|
||||||
const storedUrl =
|
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
|
||||||
if (storedUrl) {
|
|
||||||
console.log(success(`✅ Twilio sender webhook set to ${storedUrl}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isVerbose())
|
|
||||||
console.error(
|
|
||||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (isVerbose())
|
|
||||||
console.error(
|
|
||||||
"channelsSenders request update failed, will try client helpers",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1b) Form-encoded fallback for older Twilio stacks
|
|
||||||
try {
|
|
||||||
await requester.request({
|
|
||||||
method: "post",
|
|
||||||
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
|
||||||
form: {
|
|
||||||
"Webhook.CallbackUrl": url,
|
|
||||||
"Webhook.CallbackMethod": method,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const fetched = await clientTyped.messaging.v2
|
|
||||||
.channelsSenders(senderSid)
|
|
||||||
.fetch();
|
|
||||||
const storedUrl =
|
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
|
||||||
if (storedUrl) {
|
|
||||||
console.log(success(`✅ Twilio sender webhook set to ${storedUrl}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isVerbose())
|
|
||||||
console.error(
|
|
||||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (isVerbose())
|
|
||||||
console.error(
|
|
||||||
"Form channelsSenders update failed, will try helper fallback",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) SDK helper fallback (if supported by this client)
|
|
||||||
try {
|
|
||||||
if (clientTyped.messaging?.v2?.channelsSenders) {
|
|
||||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
|
||||||
callbackUrl: url,
|
|
||||||
callbackMethod: method,
|
|
||||||
});
|
|
||||||
const fetched = await clientTyped.messaging.v2
|
|
||||||
.channelsSenders(senderSid)
|
|
||||||
.fetch();
|
|
||||||
const storedUrl =
|
|
||||||
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
|
||||||
console.log(
|
|
||||||
success(
|
|
||||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isVerbose())
|
|
||||||
console.error(
|
|
||||||
"channelsSenders helper update failed, will try phone number fallback",
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Incoming phone number fallback (works for many WA senders)
|
|
||||||
try {
|
|
||||||
const phoneSid = await findIncomingNumberSid(clientTyped);
|
|
||||||
if (phoneSid) {
|
|
||||||
const phoneNumberUpdater = clientTyped.incomingPhoneNumbers(phoneSid);
|
|
||||||
await phoneNumberUpdater.update({
|
|
||||||
smsUrl: url,
|
|
||||||
smsMethod: method,
|
|
||||||
});
|
|
||||||
console.log(success(`✅ Twilio phone webhook set to ${url}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isVerbose()) console.error("Incoming number update failed", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Messaging Service fallback (some WA senders are tied to a service)
|
|
||||||
const messagingServiceUpdated = await setMessagingServiceWebhook(
|
|
||||||
clientTyped,
|
|
||||||
url,
|
|
||||||
method,
|
|
||||||
);
|
|
||||||
if (messagingServiceUpdated) return;
|
|
||||||
|
|
||||||
runtime.error(danger("Failed to set Twilio webhook."));
|
|
||||||
runtime.error(
|
|
||||||
info(
|
|
||||||
"Double-check your sender SID and credentials; you can set TWILIO_SENDER_SID to force a specific sender.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
runtime.error(
|
|
||||||
info(
|
|
||||||
"Tip: if webhooks are blocked, use polling instead: `pnpm warelay relay --provider twilio --interval 5 --lookback 10`",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
runtime.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||||
|
|
@ -1018,65 +591,23 @@ async function monitorTwilio(
|
||||||
clientOverride?: ReturnType<typeof createClient>,
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
maxIterations = Infinity,
|
maxIterations = Infinity,
|
||||||
) {
|
) {
|
||||||
// Poll Twilio for inbound messages and stream them with de-dupe.
|
// Delegate to the refactored monitor in src/twilio/monitor.ts.
|
||||||
const env = readEnv();
|
return monitorTwilioImpl(
|
||||||
const client = clientOverride ?? createClient(env);
|
intervalSeconds,
|
||||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
lookbackMinutes,
|
||||||
|
{
|
||||||
let since = new Date(Date.now() - lookbackMinutes * 60_000);
|
client: clientOverride,
|
||||||
const seen = new Set<string>();
|
maxIterations,
|
||||||
|
deps: {
|
||||||
console.log(
|
autoReplyIfConfigured,
|
||||||
`📡 Monitoring inbound messages to ${from} (poll ${intervalSeconds}s, lookback ${lookbackMinutes}m)`,
|
listRecentMessages,
|
||||||
|
readEnv,
|
||||||
|
createClient,
|
||||||
|
sleep,
|
||||||
|
},
|
||||||
|
runtime: defaultRuntime,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateSince = (date?: Date | null) => {
|
|
||||||
if (!date) return;
|
|
||||||
if (date.getTime() > since.getTime()) {
|
|
||||||
since = date;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let keepRunning = true;
|
|
||||||
process.once("SIGINT", () => {
|
|
||||||
if (!keepRunning) return;
|
|
||||||
keepRunning = false;
|
|
||||||
console.log("\n👋 Stopping monitor");
|
|
||||||
});
|
|
||||||
|
|
||||||
let iterations = 0;
|
|
||||||
while (keepRunning && iterations < maxIterations) {
|
|
||||||
try {
|
|
||||||
const messages = await client.messages.list({
|
|
||||||
to: from,
|
|
||||||
dateSentAfter: since,
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const inboundMessages = 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const m of inboundMessages) {
|
|
||||||
if (seen.has(m.sid)) continue;
|
|
||||||
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);
|
|
||||||
void autoReplyIfConfigured(client, m);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error while polling messages", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(intervalSeconds * 1000);
|
|
||||||
iterations += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function monitorWebProvider(
|
async function monitorWebProvider(
|
||||||
|
|
@ -1359,8 +890,10 @@ async function listRecentMessages(
|
||||||
limit: fetchLimit,
|
limit: fetchLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||||
|
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||||
const combined = uniqueBySid(
|
const combined = uniqueBySid(
|
||||||
[...inbound, ...outbound].map((m) => ({
|
[...inboundArr, ...outboundArr].map((m) => ({
|
||||||
sid: m.sid,
|
sid: m.sid,
|
||||||
status: m.status ?? null,
|
status: m.status ?? null,
|
||||||
direction: m.direction ?? null,
|
direction: m.direction ?? null,
|
||||||
|
|
@ -1376,227 +909,6 @@ async function listRecentMessages(
|
||||||
return sortByDateDesc(combined).slice(0, limit);
|
return sortByDateDesc(combined).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
program
|
|
||||||
.name("warelay")
|
|
||||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
|
||||||
.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));
|
|
||||||
try {
|
|
||||||
await loginWeb(Boolean(opts.verbose));
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command("login")
|
|
||||||
.description("Alias for web:login (personal WhatsApp Web QR link)")
|
|
||||||
.option("--verbose", "Verbose connection logs", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
setVerbose(Boolean(opts.verbose));
|
|
||||||
try {
|
|
||||||
await loginWeb(Boolean(opts.verbose));
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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")
|
|
||||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
|
||||||
.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 deps = createDefaultDeps();
|
|
||||||
try {
|
|
||||||
await sendCommand(opts, deps, defaultRuntime);
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(String(err));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command("relay")
|
|
||||||
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
|
|
||||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
|
||||||
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
|
|
||||||
.option(
|
|
||||||
"-l, --lookback <minutes>",
|
|
||||||
"Initial lookback window for twilio mode",
|
|
||||||
"5",
|
|
||||||
)
|
|
||||||
.option("--verbose", "Verbose logging", false)
|
|
||||||
.addHelpText(
|
|
||||||
"after",
|
|
||||||
`
|
|
||||||
Examples:
|
|
||||||
warelay relay # auto: web if logged-in, else twilio poll
|
|
||||||
warelay relay --provider web # force personal web session
|
|
||||||
warelay relay --provider twilio # force twilio poll
|
|
||||||
warelay relay --provider twilio --interval 2 --lookback 30
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.action(async (opts) => {
|
|
||||||
setVerbose(Boolean(opts.verbose));
|
|
||||||
const providerPref = String(opts.provider ?? "auto");
|
|
||||||
if (!["auto", "web", "twilio"].includes(providerPref)) {
|
|
||||||
defaultRuntime.error("--provider must be auto, web, or twilio");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
const intervalSeconds = Number.parseInt(opts.interval, 10);
|
|
||||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
|
||||||
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
|
||||||
defaultRuntime.error("Interval must be a positive integer");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
|
|
||||||
defaultRuntime.error("Lookback must be >= 0 minutes");
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = await pickProvider(providerPref as Provider | "auto");
|
|
||||||
|
|
||||||
if (provider === "web") {
|
|
||||||
defaultRuntime.log(info("Provider: web (personal WhatsApp Web session)"));
|
|
||||||
logWebSelfId();
|
|
||||||
try {
|
|
||||||
await monitorWebProvider(Boolean(opts.verbose));
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
if (providerPref === "auto") {
|
|
||||||
defaultRuntime.error(
|
|
||||||
warn("Web session unavailable; falling back to twilio."),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureTwilioEnv();
|
|
||||||
logTwilioFrom();
|
|
||||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command("status")
|
|
||||||
.description("Show recent WhatsApp messages (sent and received)")
|
|
||||||
.option("-l, --limit <count>", "Number of messages to show", "20")
|
|
||||||
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
|
|
||||||
.option("--json", "Output JSON instead of text", false)
|
|
||||||
.addHelpText(
|
|
||||||
"after",
|
|
||||||
`
|
|
||||||
Examples:
|
|
||||||
warelay status # last 20 msgs in past 4h
|
|
||||||
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
|
||||||
warelay status --json --limit 50 # machine-readable output`,
|
|
||||||
)
|
|
||||||
.action(async (opts) => {
|
|
||||||
const deps = createDefaultDeps();
|
|
||||||
try {
|
|
||||||
await statusCommand(opts, deps, defaultRuntime);
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(String(err));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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)
|
|
||||||
.option("-y, --yes", "Auto-confirm prompts when possible", 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)`,
|
|
||||||
)
|
|
||||||
// istanbul ignore next
|
|
||||||
.action(async (opts) => {
|
|
||||||
setVerbose(Boolean(opts.verbose));
|
|
||||||
setYes(Boolean(opts.yes));
|
|
||||||
const deps = createDefaultDeps();
|
|
||||||
try {
|
|
||||||
const server = await webhookCommand(opts, deps, defaultRuntime);
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
server.close(() => {
|
|
||||||
console.log("\n👋 Webhook stopped");
|
|
||||||
defaultRuntime.exit(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await deps.waitForever();
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(String(err));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command("up")
|
|
||||||
.description(
|
|
||||||
"Bring up webhook + Tailscale Funnel + Twilio callback (default webhook mode)",
|
|
||||||
)
|
|
||||||
.option("-p, --port <port>", "Port to listen on", "42873")
|
|
||||||
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
|
|
||||||
.option("--verbose", "Verbose logging during setup/webhook", false)
|
|
||||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
|
||||||
// istanbul ignore next
|
|
||||||
.action(async (opts) => {
|
|
||||||
setVerbose(Boolean(opts.verbose));
|
|
||||||
setYes(Boolean(opts.yes));
|
|
||||||
const deps = createDefaultDeps();
|
|
||||||
try {
|
|
||||||
const { server } = await upCommand(opts, deps, defaultRuntime);
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
server.close(() => {
|
|
||||||
console.log("\n👋 Webhook stopped");
|
|
||||||
defaultRuntime.exit(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await deps.waitForever();
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(String(err));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
assertProvider,
|
assertProvider,
|
||||||
autoReplyIfConfigured,
|
autoReplyIfConfigured,
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { execFile, spawn } from "node:child_process";
|
||||||
|
|
||||||
import { danger, isVerbose } from "../globals.js";
|
import { danger, isVerbose } from "../globals.js";
|
||||||
|
|
||||||
|
// Simple promise-wrapped execFile with optional verbosity logging.
|
||||||
export async function runExec(
|
export async function runExec(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
timeoutMs = 10_000,
|
timeoutMs = 10_000,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
// Simple promise-wrapped execFile with optional verbosity logging.
|
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execFile(command, args, {
|
const { stdout, stderr } = await execFile(command, args, {
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { monitorTwilio } from "./monitor.js";
|
||||||
|
|
||||||
|
describe("monitorTwilio", () => {
|
||||||
|
it("processes inbound messages once with injected deps", async () => {
|
||||||
|
const listRecentMessages = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
sid: "m1",
|
||||||
|
direction: "inbound",
|
||||||
|
dateCreated: new Date(),
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
body: "hi",
|
||||||
|
errorCode: null,
|
||||||
|
errorMessage: null,
|
||||||
|
status: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const readEnv = vi.fn(() => ({ accountSid: "AC", whatsappFrom: "whatsapp:+1", auth: { accountSid: "AC", authToken: "t" } }));
|
||||||
|
const createClient = vi.fn(() => ({ messages: { create: vi.fn() } } as never));
|
||||||
|
const sleep = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await monitorTwilio(0, 0, {
|
||||||
|
deps: { autoReplyIfConfigured, listRecentMessages, readEnv, createClient, sleep },
|
||||||
|
maxIterations: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listRecentMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import chalk from "chalk";
|
||||||
|
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||||
|
|
||||||
|
import { danger, info, logVerbose, warn } from "../globals.js";
|
||||||
|
import { sleep, withWhatsAppPrefix } from "../utils.js";
|
||||||
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
|
||||||
|
import { createClient } from "./client.js";
|
||||||
|
import { readEnv } from "../env.js";
|
||||||
|
|
||||||
|
type MonitorDeps = {
|
||||||
|
autoReplyIfConfigured: typeof autoReplyIfConfigured;
|
||||||
|
listRecentMessages: (
|
||||||
|
lookbackMinutes: number,
|
||||||
|
limit: number,
|
||||||
|
clientOverride?: ReturnType<typeof createClient>,
|
||||||
|
) => Promise<ListedMessage[]>;
|
||||||
|
readEnv: typeof readEnv;
|
||||||
|
createClient: typeof createClient;
|
||||||
|
sleep: typeof sleep;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||||
|
|
||||||
|
export type ListedMessage = {
|
||||||
|
sid: string;
|
||||||
|
status: string | null;
|
||||||
|
direction: string | null;
|
||||||
|
dateCreated: Date | undefined;
|
||||||
|
from?: string | null;
|
||||||
|
to?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
errorCode: number | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonitorOptions = {
|
||||||
|
client?: ReturnType<typeof createClient>;
|
||||||
|
maxIterations?: number;
|
||||||
|
deps?: MonitorDeps;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultDeps: MonitorDeps = {
|
||||||
|
autoReplyIfConfigured,
|
||||||
|
listRecentMessages: () => Promise.resolve([]),
|
||||||
|
readEnv,
|
||||||
|
createClient,
|
||||||
|
sleep,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Poll Twilio for inbound messages and auto-reply when configured.
|
||||||
|
export async function monitorTwilio(
|
||||||
|
pollSeconds: number,
|
||||||
|
lookbackMinutes: number,
|
||||||
|
opts?: MonitorOptions,
|
||||||
|
) {
|
||||||
|
const deps = opts?.deps ?? defaultDeps;
|
||||||
|
const runtime = opts?.runtime ?? defaultRuntime;
|
||||||
|
const maxIterations = opts?.maxIterations ?? Infinity;
|
||||||
|
|
||||||
|
const env = deps.readEnv(runtime);
|
||||||
|
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||||
|
const client = opts?.client ?? deps.createClient(env);
|
||||||
|
console.log(
|
||||||
|
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let lastSeenSid: string | undefined;
|
||||||
|
let iterations = 0;
|
||||||
|
while (iterations < maxIterations) {
|
||||||
|
const messages =
|
||||||
|
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||||
|
const inboundOnly = messages.filter((m) => m.direction === "inbound");
|
||||||
|
// Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports).
|
||||||
|
const newestFirst = [...inboundOnly].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0),
|
||||||
|
);
|
||||||
|
await handleMessages(messages, client, lastSeenSid, deps, runtime);
|
||||||
|
lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid;
|
||||||
|
iterations += 1;
|
||||||
|
if (iterations >= maxIterations) break;
|
||||||
|
await deps.sleep(Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessages(
|
||||||
|
messages: ListedMessage[],
|
||||||
|
client: ReturnType<typeof createClient>,
|
||||||
|
lastSeenSid: string | undefined,
|
||||||
|
deps: MonitorDeps,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
) {
|
||||||
|
for (const m of messages) {
|
||||||
|
if (!m.sid) continue;
|
||||||
|
if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen
|
||||||
|
logVerbose(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`);
|
||||||
|
if (m.direction !== "inbound") continue;
|
||||||
|
if (!m.from || !m.to) continue;
|
||||||
|
try {
|
||||||
|
await deps.autoReplyIfConfigured(
|
||||||
|
client as unknown as {
|
||||||
|
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||||
|
},
|
||||||
|
m as unknown as MessageInstance,
|
||||||
|
undefined,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { waitForFinalStatus } from "./send.js";
|
||||||
|
|
||||||
|
describe("twilio send helpers", () => {
|
||||||
|
it("waitForFinalStatus resolves on delivered", async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ status: "queued" })
|
||||||
|
.mockResolvedValueOnce({ status: "delivered" });
|
||||||
|
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||||
|
await waitForFinalStatus(client, "SM1", 2, 0.01, console as never);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waitForFinalStatus exits on failure", async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue({ status: "failed", errorMessage: "boom" });
|
||||||
|
const client = { messages: vi.fn(() => ({ fetch })) } as never;
|
||||||
|
const runtime = { log: console.log, error: () => {}, exit: vi.fn(() => { throw new Error("exit"); }) } as never;
|
||||||
|
await expect(waitForFinalStatus(client, "SM1", 1, 0.01, runtime)).rejects.toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { success } from "../globals.js";
|
||||||
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { withWhatsAppPrefix, sleep } from "../utils.js";
|
||||||
|
import { readEnv } from "../env.js";
|
||||||
|
import { createClient } from "./client.js";
|
||||||
|
import { logTwilioSendError } from "./utils.js";
|
||||||
|
|
||||||
|
const successTerminalStatuses = new Set(["delivered", "read"]);
|
||||||
|
const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]);
|
||||||
|
|
||||||
|
// Send outbound WhatsApp message; exit non-zero on API failure.
|
||||||
|
export async function sendMessage(
|
||||||
|
to: string,
|
||||||
|
body: string,
|
||||||
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
|
) {
|
||||||
|
const env = readEnv(runtime);
|
||||||
|
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(
|
||||||
|
success(
|
||||||
|
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { client, sid: message.sid };
|
||||||
|
} catch (err) {
|
||||||
|
logTwilioSendError(err, toNumber, runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll message status until delivered/failed or timeout.
|
||||||
|
export async function waitForFinalStatus(
|
||||||
|
client: ReturnType<typeof createClient>,
|
||||||
|
sid: string,
|
||||||
|
timeoutSeconds: number,
|
||||||
|
pollSeconds: number,
|
||||||
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
|
) {
|
||||||
|
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(success(`✅ Delivered (status: ${status})`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (failureTerminalStatuses.has(status)) {
|
||||||
|
runtime.error(
|
||||||
|
`❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`,
|
||||||
|
);
|
||||||
|
runtime.exit(1);
|
||||||
|
}
|
||||||
|
await sleep(pollSeconds * 1000);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
export type TwilioRequestOptions = {
|
||||||
|
method: "get" | "post";
|
||||||
|
uri: string;
|
||||||
|
params?: Record<string, string | number>;
|
||||||
|
form?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
contentType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TwilioSender = { sid: string; sender_id: string };
|
||||||
|
|
||||||
|
export type TwilioRequestResponse = {
|
||||||
|
data?: {
|
||||||
|
senders?: TwilioSender[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomingNumber = {
|
||||||
|
sid: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
smsUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TwilioChannelsSender = {
|
||||||
|
sid?: string;
|
||||||
|
senderId?: string;
|
||||||
|
sender_id?: string;
|
||||||
|
webhook?: {
|
||||||
|
callback_url?: string;
|
||||||
|
callback_method?: string;
|
||||||
|
fallback_url?: string;
|
||||||
|
fallback_method?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelSenderUpdater = {
|
||||||
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomingPhoneNumberUpdater = {
|
||||||
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomingPhoneNumbersClient = {
|
||||||
|
list: (params: {
|
||||||
|
phoneNumber: string;
|
||||||
|
limit?: number;
|
||||||
|
}) => Promise<IncomingNumber[]>;
|
||||||
|
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||||
|
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
||||||
|
|
||||||
|
export type TwilioSenderListClient = {
|
||||||
|
messaging: {
|
||||||
|
v2: {
|
||||||
|
channelsSenders: {
|
||||||
|
list: (params: {
|
||||||
|
channel: string;
|
||||||
|
pageSize: number;
|
||||||
|
}) => Promise<TwilioChannelsSender[]>;
|
||||||
|
(
|
||||||
|
sid: string,
|
||||||
|
): ChannelSenderUpdater & {
|
||||||
|
fetch: () => Promise<TwilioChannelsSender>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
v1: {
|
||||||
|
services: (sid: string) => {
|
||||||
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
|
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TwilioRequester = {
|
||||||
|
request: (options: TwilioRequestOptions) => Promise<TwilioRequestResponse>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
findIncomingNumberSid,
|
||||||
|
findMessagingServiceSid,
|
||||||
|
setMessagingServiceWebhook,
|
||||||
|
} from "./update-webhook.js";
|
||||||
|
|
||||||
|
const envBackup = { ...process.env } as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
describe("update-webhook helpers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.TWILIO_ACCOUNT_SID = "AC";
|
||||||
|
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.entries(envBackup).forEach(([k, v]) => {
|
||||||
|
if (v === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = v;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findIncomingNumberSid returns first match", async () => {
|
||||||
|
const client = {
|
||||||
|
incomingPhoneNumbers: {
|
||||||
|
list: async () => [{ sid: "PN1", phoneNumber: "+1555" }],
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
const sid = await findIncomingNumberSid(client);
|
||||||
|
expect(sid).toBe("PN1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findMessagingServiceSid reads messagingServiceSid", async () => {
|
||||||
|
const client = {
|
||||||
|
incomingPhoneNumbers: {
|
||||||
|
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
const sid = await findMessagingServiceSid(client);
|
||||||
|
expect(sid).toBe("MG1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setMessagingServiceWebhook updates via service helper", async () => {
|
||||||
|
const update = async (_: unknown) => {};
|
||||||
|
const fetch = async () => ({ inboundRequestUrl: "https://cb" });
|
||||||
|
const client = {
|
||||||
|
messaging: {
|
||||||
|
v1: {
|
||||||
|
services: () => ({ update, fetch }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
incomingPhoneNumbers: {
|
||||||
|
list: async () => [{ messagingServiceSid: "MG1" }],
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
const ok = await setMessagingServiceWebhook(client, "https://cb", "POST");
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { success, isVerbose, warn } from "../globals.js";
|
||||||
|
import { readEnv } from "../env.js";
|
||||||
|
import { normalizeE164 } from "../utils.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { createClient } from "./client.js";
|
||||||
|
import type { TwilioSenderListClient, TwilioRequester } from "./types.js";
|
||||||
|
|
||||||
|
export async function findIncomingNumberSid(client: TwilioSenderListClient): Promise<string | null> {
|
||||||
|
// Look up incoming phone number SID matching the configured WhatsApp number.
|
||||||
|
try {
|
||||||
|
const env = readEnv();
|
||||||
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
|
phoneNumber: phone,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return list?.[0]?.sid ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findMessagingServiceSid(client: TwilioSenderListClient): Promise<string | null> {
|
||||||
|
// Attempt to locate a messaging service tied to the WA phone number (webhook fallback).
|
||||||
|
type IncomingNumberWithService = { messagingServiceSid?: string };
|
||||||
|
try {
|
||||||
|
const env = readEnv();
|
||||||
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
|
phoneNumber: phone,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
const msid =
|
||||||
|
(list?.[0] as IncomingNumberWithService | undefined)
|
||||||
|
?.messagingServiceSid ?? null;
|
||||||
|
return msid;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMessagingServiceWebhook(
|
||||||
|
client: TwilioSenderListClient,
|
||||||
|
url: string,
|
||||||
|
method: "POST" | "GET",
|
||||||
|
): Promise<boolean> {
|
||||||
|
const msid = await findMessagingServiceSid(client);
|
||||||
|
if (!msid) return false;
|
||||||
|
try {
|
||||||
|
await client.messaging.v1.services(msid).update({
|
||||||
|
InboundRequestUrl: url,
|
||||||
|
InboundRequestMethod: method,
|
||||||
|
});
|
||||||
|
const fetched = await client.messaging.v1.services(msid).fetch();
|
||||||
|
const stored = fetched?.inboundRequestUrl;
|
||||||
|
console.log(
|
||||||
|
success(
|
||||||
|
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Update sender webhook URL with layered fallbacks (channels, form, helper, phone).
|
||||||
|
export async function updateWebhook(
|
||||||
|
client: ReturnType<typeof createClient>,
|
||||||
|
senderSid: string,
|
||||||
|
url: string,
|
||||||
|
method: "POST" | "GET" = "POST",
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
) {
|
||||||
|
// Point Twilio sender webhook at the provided URL.
|
||||||
|
const requester = client as unknown as TwilioRequester;
|
||||||
|
const clientTyped = client as unknown as TwilioSenderListClient;
|
||||||
|
|
||||||
|
// 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA
|
||||||
|
try {
|
||||||
|
await requester.request({
|
||||||
|
method: "post",
|
||||||
|
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||||
|
body: {
|
||||||
|
webhook: {
|
||||||
|
callback_url: url,
|
||||||
|
callback_method: method,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
const fetched = await clientTyped.messaging.v2
|
||||||
|
.channelsSenders(senderSid)
|
||||||
|
.fetch();
|
||||||
|
const storedUrl =
|
||||||
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
|
if (storedUrl) {
|
||||||
|
console.log(success(`✅ Twilio sender webhook set to ${storedUrl}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isVerbose())
|
||||||
|
console.error(
|
||||||
|
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (isVerbose())
|
||||||
|
console.error(
|
||||||
|
"channelsSenders request update failed, will try client helpers",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b) Form-encoded fallback for older Twilio stacks
|
||||||
|
try {
|
||||||
|
await requester.request({
|
||||||
|
method: "post",
|
||||||
|
uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`,
|
||||||
|
form: {
|
||||||
|
"Webhook.CallbackUrl": url,
|
||||||
|
"Webhook.CallbackMethod": method,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fetched = await clientTyped.messaging.v2
|
||||||
|
.channelsSenders(senderSid)
|
||||||
|
.fetch();
|
||||||
|
const storedUrl =
|
||||||
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
|
if (storedUrl) {
|
||||||
|
console.log(success(`✅ Twilio sender webhook set to ${storedUrl}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isVerbose())
|
||||||
|
console.error(
|
||||||
|
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (isVerbose())
|
||||||
|
console.error(
|
||||||
|
"Form channelsSenders update failed, will try helper fallback",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) SDK helper fallback (if supported by this client)
|
||||||
|
try {
|
||||||
|
if (clientTyped.messaging?.v2?.channelsSenders) {
|
||||||
|
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||||
|
callbackUrl: url,
|
||||||
|
callbackMethod: method,
|
||||||
|
});
|
||||||
|
const fetched = await clientTyped.messaging.v2
|
||||||
|
.channelsSenders(senderSid)
|
||||||
|
.fetch();
|
||||||
|
const storedUrl =
|
||||||
|
fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url;
|
||||||
|
console.log(
|
||||||
|
success(
|
||||||
|
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isVerbose())
|
||||||
|
console.error(
|
||||||
|
"channelsSenders helper update failed, will try phone number fallback",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Incoming phone number fallback (works for many WA senders)
|
||||||
|
try {
|
||||||
|
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||||
|
if (phoneSid) {
|
||||||
|
await clientTyped.incomingPhoneNumbers(phoneSid).update({
|
||||||
|
smsUrl: url,
|
||||||
|
smsMethod: method,
|
||||||
|
});
|
||||||
|
console.log(success(`✅ Phone webhook set to ${url} (number ${phoneSid})`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isVerbose())
|
||||||
|
console.error(
|
||||||
|
"Incoming phone number webhook update failed; no more fallbacks",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.error(
|
||||||
|
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import express, { type Request, type Response } from "express";
|
||||||
|
import bodyParser from "body-parser";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import type { Server } from "http";
|
||||||
|
|
||||||
|
import { success, logVerbose, danger } from "../globals.js";
|
||||||
|
import { readEnv } from "../env.js";
|
||||||
|
import { createClient } from "./client.js";
|
||||||
|
import { normalizePath } from "../utils.js";
|
||||||
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import { sendTypingIndicator } from "./typing.js";
|
||||||
|
import { logTwilioSendError } from "./utils.js";
|
||||||
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
|
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
||||||
|
export async function startWebhook(
|
||||||
|
port: number,
|
||||||
|
path = "/webhook/whatsapp",
|
||||||
|
autoReply: string | undefined,
|
||||||
|
verbose: boolean,
|
||||||
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
|
): Promise<Server> {
|
||||||
|
const normalizedPath = normalizePath(path);
|
||||||
|
const env = readEnv(runtime);
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Twilio sends application/x-www-form-urlencoded payloads.
|
||||||
|
app.use(bodyParser.urlencoded({ extended: false }));
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`));
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(normalizedPath, async (req: Request, res: Response) => {
|
||||||
|
const { From, To, Body, MessageSid } = req.body ?? {};
|
||||||
|
runtime.log(`
|
||||||
|
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
||||||
|
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||||
|
|
||||||
|
const client = createClient(env);
|
||||||
|
let replyText = autoReply;
|
||||||
|
if (!replyText) {
|
||||||
|
replyText = await getReplyFromConfig(
|
||||||
|
{ Body, From, To, MessageSid },
|
||||||
|
{
|
||||||
|
onReplyStart: () => sendTypingIndicator(client, MessageSid, runtime),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyText) {
|
||||||
|
try {
|
||||||
|
await client.messages.create({ from: To, to: From, body: replyText });
|
||||||
|
if (verbose) runtime.log(success(`↩️ Auto-replied to ${From}`));
|
||||||
|
} catch (err) {
|
||||||
|
logTwilioSendError(err, From ?? undefined, runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond 200 OK to Twilio.
|
||||||
|
res.type("text/xml").send("<Response></Response>");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((_req, res) => {
|
||||||
|
if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`));
|
||||||
|
res.status(404).send("warelay webhook: not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server and resolve once listening; reject on bind error.
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = app.listen(port);
|
||||||
|
|
||||||
|
const onListening = () => {
|
||||||
|
cleanup();
|
||||||
|
runtime.log(
|
||||||
|
`📥 Webhook listening on http://localhost:${port}${normalizedPath}`,
|
||||||
|
);
|
||||||
|
resolve(server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: NodeJS.ErrnoException) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
server.off("listening", onListening);
|
||||||
|
server.off("error", onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
server.once("listening", onListening);
|
||||||
|
server.once("error", onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue