Add messaging service webhook fallback; always log inbound
This commit is contained in:
@@ -28,6 +28,8 @@ Small TypeScript CLI to send, monitor, and webhook WhatsApp messages via Twilio.
|
||||
- Polling mode (no webhooks/funnel): `pnpm warelay poll --interval 5 --lookback 10 --verbose`
|
||||
- Useful fallback if Twilio webhook can’t reach you.
|
||||
- Still runs config-driven auto-replies (including command-mode/Claude) for new inbound messages.
|
||||
- Status: `pnpm warelay status --limit 20 --lookback 240`
|
||||
- Lists recent sent/received WhatsApp messages (merged and sorted), defaulting to 20 messages from the past 4 hours. Add `--json` for machine-readable output.
|
||||
|
||||
## Config-driven auto-replies
|
||||
|
||||
@@ -61,6 +63,7 @@ Put a JSON5 config at `~/.warelay/warelay.json`. Examples:
|
||||
- `inbound.reply.text?: string` — used when `mode` is `text`; supports `{{Body}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`.
|
||||
- `inbound.reply.command?: string[]` — argv for the command to run; templated per element.
|
||||
- `inbound.reply.template?: string` — optional string prepended as the second argv element (handy for adding a prompt prefix).
|
||||
- `inbound.reply.bodyPrefix?: string` — optional string prepended to `Body` before templating (useful to add system instructions, e.g., `You are a helpful assistant running on the user's Mac. User writes messages via WhatsApp and you respond. You want to be concise in your responses, at most 1000 characters.\n\n`).
|
||||
|
||||
Example with an allowlist and Claude CLI one-shot (uses a sample number):
|
||||
|
||||
@@ -91,3 +94,4 @@ During dev you can run without building: `pnpm dev -- <subcommand>` (e.g. `pnpm
|
||||
|
||||
- Monitor uses polling; webhook mode is push (recommended).
|
||||
- Stop monitor/webhook with `Ctrl+C`.
|
||||
- When an auto-reply is triggered (text or command mode), warelay immediately posts a WhatsApp typing indicator tied to the inbound `MessageSid` so the user sees “typing…” while your handler runs.
|
||||
|
||||
263
src/index.ts
263
src/index.ts
@@ -100,6 +100,7 @@ type TwilioSenderListClient = {
|
||||
v1: {
|
||||
services: (sid: string) => {
|
||||
update: (params: Record<string, string>) => Promise<unknown>;
|
||||
fetch: () => Promise<{ inboundRequestUrl?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -375,6 +376,7 @@ type WarelayConfig = {
|
||||
command?: string[]; // for mode=command, argv with templates
|
||||
template?: string; // prepend template string when building command/prompt
|
||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
||||
bodyPrefix?: string; // optional string prepended to Body before templating
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -400,6 +402,10 @@ type MsgContext = {
|
||||
MessageSid?: string;
|
||||
};
|
||||
|
||||
type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
function applyTemplate(str: string, ctx: MsgContext) {
|
||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
@@ -410,12 +416,28 @@ function applyTemplate(str: string, ctx: MsgContext) {
|
||||
|
||||
async function getReplyFromConfig(
|
||||
ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
): Promise<string | undefined> {
|
||||
// Choose reply from config: static text or external command stdout.
|
||||
const cfg = loadConfig();
|
||||
const reply = cfg.inbound?.reply;
|
||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let started = false;
|
||||
const onReplyStart = async () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
await opts?.onReplyStart?.();
|
||||
};
|
||||
|
||||
// Optional prefix injected before Body for templating/command prompts.
|
||||
const bodyPrefix = reply?.bodyPrefix
|
||||
? applyTemplate(reply.bodyPrefix, ctx)
|
||||
: "";
|
||||
const templatingCtx: MsgContext =
|
||||
bodyPrefix && (ctx.Body ?? "").length >= 0
|
||||
? { ...ctx, Body: `${bodyPrefix}${ctx.Body ?? ""}` }
|
||||
: ctx;
|
||||
|
||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||
const allowFrom = cfg.inbound?.allowFrom;
|
||||
@@ -434,14 +456,18 @@ async function getReplyFromConfig(
|
||||
}
|
||||
|
||||
if (reply.mode === "text" && reply.text) {
|
||||
await onReplyStart();
|
||||
logVerbose("Using text auto-reply from config");
|
||||
return applyTemplate(reply.text, ctx);
|
||||
return applyTemplate(reply.text, templatingCtx);
|
||||
}
|
||||
|
||||
if (reply.mode === "command" && reply.command?.length) {
|
||||
const argv = reply.command.map((part) => applyTemplate(part, ctx));
|
||||
await onReplyStart();
|
||||
const argv = reply.command.map((part) =>
|
||||
applyTemplate(part, templatingCtx),
|
||||
);
|
||||
const templatePrefix = reply.template
|
||||
? applyTemplate(reply.template, ctx)
|
||||
? applyTemplate(reply.template, templatingCtx)
|
||||
: "";
|
||||
const finalArgv = templatePrefix
|
||||
? [argv[0], templatePrefix, ...argv.slice(1)]
|
||||
@@ -509,7 +535,9 @@ async function autoReplyIfConfigured(
|
||||
MessageSid: message.sid,
|
||||
};
|
||||
|
||||
const replyText = await getReplyFromConfig(ctx);
|
||||
const replyText = await getReplyFromConfig(ctx, {
|
||||
onReplyStart: () => sendTypingIndicator(client, message.sid),
|
||||
});
|
||||
if (!replyText) return;
|
||||
|
||||
const replyFrom = message.to;
|
||||
@@ -557,6 +585,34 @@ function createClient(env: EnvConfig) {
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTypingIndicator(
|
||||
client: ReturnType<typeof createClient>,
|
||||
messageSid?: string,
|
||||
) {
|
||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||
if (!messageSid) {
|
||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const requester = client as unknown as TwilioRequester;
|
||||
await requester.request({
|
||||
method: "post",
|
||||
uri: "https://messaging.twilio.com/v2/Indicators/Typing.json",
|
||||
form: {
|
||||
messageId: messageSid,
|
||||
channel: "whatsapp",
|
||||
},
|
||||
});
|
||||
logVerbose(`Sent typing indicator for inbound ${messageSid}`);
|
||||
} catch (err) {
|
||||
if (globalVerbose) {
|
||||
console.error(warn("Typing indicator failed (continuing without it)"));
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(to: string, body: string) {
|
||||
// Send outbound WhatsApp message; exit non-zero on API failure.
|
||||
const env = readEnv();
|
||||
@@ -664,19 +720,24 @@ async function startWebhook(
|
||||
);
|
||||
if (verbose) console.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||
|
||||
const client = createClient(env);
|
||||
let replyText = autoReply;
|
||||
if (!replyText) {
|
||||
replyText = await getReplyFromConfig({
|
||||
Body,
|
||||
From,
|
||||
To,
|
||||
MessageSid,
|
||||
});
|
||||
replyText = await getReplyFromConfig(
|
||||
{
|
||||
Body,
|
||||
From,
|
||||
To,
|
||||
MessageSid,
|
||||
},
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, MessageSid),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (replyText) {
|
||||
try {
|
||||
const client = createClient(env);
|
||||
await client.messages.create({
|
||||
from: To,
|
||||
to: From,
|
||||
@@ -949,6 +1010,49 @@ async function findIncomingNumberSid(
|
||||
}
|
||||
}
|
||||
|
||||
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 (globalVerbose) console.error("findMessagingServiceSid failed", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
if (globalVerbose)
|
||||
console.log(chalk.gray(`Updated Messaging Service ${msid} inbound URL`));
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (globalVerbose) console.error("Messaging Service update failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateWebhook(
|
||||
client: ReturnType<typeof createClient>,
|
||||
senderSid: string,
|
||||
@@ -1013,6 +1117,14 @@ async function updateWebhook(
|
||||
if (globalVerbose) 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;
|
||||
|
||||
console.error(danger("Failed to set Twilio webhook."));
|
||||
console.error(
|
||||
info(
|
||||
@@ -1092,6 +1204,95 @@ async function monitor(intervalSeconds: number, lookbackMinutes: number) {
|
||||
}
|
||||
}
|
||||
|
||||
type ListedMessage = {
|
||||
sid: string;
|
||||
status: string | null;
|
||||
direction: string | null;
|
||||
dateCreated?: Date | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
body?: string | null;
|
||||
errorCode?: number | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
function uniqueBySid(messages: ListedMessage[]): ListedMessage[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: ListedMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (seen.has(m.sid)) continue;
|
||||
seen.add(m.sid);
|
||||
deduped.push(m);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] {
|
||||
return [...messages].sort((a, b) => {
|
||||
const da = a.dateCreated?.getTime() ?? 0;
|
||||
const db = b.dateCreated?.getTime() ?? 0;
|
||||
return db - da;
|
||||
});
|
||||
}
|
||||
|
||||
function formatMessageLine(m: ListedMessage): string {
|
||||
const ts = m.dateCreated?.toISOString() ?? "unknown-time";
|
||||
const dir =
|
||||
m.direction === "inbound"
|
||||
? "⬅️ "
|
||||
: m.direction === "outbound-api" || m.direction === "outbound-reply"
|
||||
? "➡️ "
|
||||
: "↔️ ";
|
||||
const status = m.status ?? "unknown";
|
||||
const err =
|
||||
m.errorCode != null
|
||||
? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}`
|
||||
: "";
|
||||
const body = (m.body ?? "").replace(/\s+/g, " ").trim();
|
||||
const bodyPreview =
|
||||
body.length > 140 ? `${body.slice(0, 137)}…` : body || "<empty>";
|
||||
return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`;
|
||||
}
|
||||
|
||||
async function listRecentMessages(
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
): Promise<ListedMessage[]> {
|
||||
const env = readEnv();
|
||||
const client = createClient(env);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const since = new Date(Date.now() - lookbackMinutes * 60_000);
|
||||
|
||||
// Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit.
|
||||
const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100);
|
||||
const inbound = await client.messages.list({
|
||||
to: from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
const outbound = await client.messages.list({
|
||||
from,
|
||||
dateSentAfter: since,
|
||||
limit: fetchLimit,
|
||||
});
|
||||
|
||||
const combined = uniqueBySid(
|
||||
[...inbound, ...outbound].map((m) => ({
|
||||
sid: m.sid,
|
||||
status: m.status ?? null,
|
||||
direction: m.direction ?? null,
|
||||
dateCreated: m.dateCreated,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
body: m.body,
|
||||
errorCode: m.errorCode ?? null,
|
||||
errorMessage: m.errorMessage ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
return sortByDateDesc(combined).slice(0, limit);
|
||||
}
|
||||
|
||||
program
|
||||
.name("warelay")
|
||||
.description("WhatsApp relay CLI using Twilio")
|
||||
@@ -1167,6 +1368,46 @@ Examples:
|
||||
await monitor(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 limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||
console.error("limit must be between 1 and 200");
|
||||
process.exit(1);
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||
console.error("lookback must be > 0 minutes");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const messages = await listRecentMessages(lookbackMinutes, limit);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
console.log("No messages found in the requested window.");
|
||||
return;
|
||||
}
|
||||
for (const m of messages) {
|
||||
console.log(formatMessageLine(m));
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("poll")
|
||||
.description("Poll Twilio for inbound WhatsApp messages (non-webhook mode)")
|
||||
|
||||
Reference in New Issue
Block a user