Add messaging service webhook fallback; always log inbound
parent
b453e285fd
commit
07f0a26419
|
|
@ -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`
|
- Polling mode (no webhooks/funnel): `pnpm warelay poll --interval 5 --lookback 10 --verbose`
|
||||||
- Useful fallback if Twilio webhook can’t reach you.
|
- Useful fallback if Twilio webhook can’t reach you.
|
||||||
- Still runs config-driven auto-replies (including command-mode/Claude) for new inbound messages.
|
- 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
|
## 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.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.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.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):
|
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).
|
- Monitor uses polling; webhook mode is push (recommended).
|
||||||
- Stop monitor/webhook with `Ctrl+C`.
|
- 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: {
|
v1: {
|
||||||
services: (sid: string) => {
|
services: (sid: string) => {
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
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
|
command?: string[]; // for mode=command, argv with templates
|
||||||
template?: string; // prepend template string when building command/prompt
|
template?: string; // prepend template string when building command/prompt
|
||||||
timeoutSeconds?: number; // optional command timeout; defaults to 600s
|
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;
|
MessageSid?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GetReplyOptions = {
|
||||||
|
onReplyStart?: () => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
function applyTemplate(str: string, ctx: MsgContext) {
|
function applyTemplate(str: string, ctx: MsgContext) {
|
||||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||||
|
|
@ -410,12 +416,28 @@ function applyTemplate(str: string, ctx: MsgContext) {
|
||||||
|
|
||||||
async function getReplyFromConfig(
|
async function getReplyFromConfig(
|
||||||
ctx: MsgContext,
|
ctx: MsgContext,
|
||||||
|
opts?: GetReplyOptions,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
// Choose reply from config: static text or external command stdout.
|
// Choose reply from config: static text or external command stdout.
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const reply = cfg.inbound?.reply;
|
const reply = cfg.inbound?.reply;
|
||||||
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
|
||||||
const timeoutMs = timeoutSeconds * 1000;
|
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)
|
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
||||||
const allowFrom = cfg.inbound?.allowFrom;
|
const allowFrom = cfg.inbound?.allowFrom;
|
||||||
|
|
@ -434,14 +456,18 @@ async function getReplyFromConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reply.mode === "text" && reply.text) {
|
if (reply.mode === "text" && reply.text) {
|
||||||
|
await onReplyStart();
|
||||||
logVerbose("Using text auto-reply from config");
|
logVerbose("Using text auto-reply from config");
|
||||||
return applyTemplate(reply.text, ctx);
|
return applyTemplate(reply.text, templatingCtx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reply.mode === "command" && reply.command?.length) {
|
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
|
const templatePrefix = reply.template
|
||||||
? applyTemplate(reply.template, ctx)
|
? applyTemplate(reply.template, templatingCtx)
|
||||||
: "";
|
: "";
|
||||||
const finalArgv = templatePrefix
|
const finalArgv = templatePrefix
|
||||||
? [argv[0], templatePrefix, ...argv.slice(1)]
|
? [argv[0], templatePrefix, ...argv.slice(1)]
|
||||||
|
|
@ -509,7 +535,9 @@ async function autoReplyIfConfigured(
|
||||||
MessageSid: message.sid,
|
MessageSid: message.sid,
|
||||||
};
|
};
|
||||||
|
|
||||||
const replyText = await getReplyFromConfig(ctx);
|
const replyText = await getReplyFromConfig(ctx, {
|
||||||
|
onReplyStart: () => sendTypingIndicator(client, message.sid),
|
||||||
|
});
|
||||||
if (!replyText) return;
|
if (!replyText) return;
|
||||||
|
|
||||||
const replyFrom = message.to;
|
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) {
|
async function sendMessage(to: string, body: string) {
|
||||||
// Send outbound WhatsApp message; exit non-zero on API failure.
|
// Send outbound WhatsApp message; exit non-zero on API failure.
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
|
|
@ -664,19 +720,24 @@ async function startWebhook(
|
||||||
);
|
);
|
||||||
if (verbose) console.log(chalk.gray(`Body: ${Body ?? ""}`));
|
if (verbose) console.log(chalk.gray(`Body: ${Body ?? ""}`));
|
||||||
|
|
||||||
|
const client = createClient(env);
|
||||||
let replyText = autoReply;
|
let replyText = autoReply;
|
||||||
if (!replyText) {
|
if (!replyText) {
|
||||||
replyText = await getReplyFromConfig({
|
replyText = await getReplyFromConfig(
|
||||||
Body,
|
{
|
||||||
From,
|
Body,
|
||||||
To,
|
From,
|
||||||
MessageSid,
|
To,
|
||||||
});
|
MessageSid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onReplyStart: () => sendTypingIndicator(client, MessageSid),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyText) {
|
if (replyText) {
|
||||||
try {
|
try {
|
||||||
const client = createClient(env);
|
|
||||||
await client.messages.create({
|
await client.messages.create({
|
||||||
from: To,
|
from: To,
|
||||||
to: From,
|
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(
|
async function updateWebhook(
|
||||||
client: ReturnType<typeof createClient>,
|
client: ReturnType<typeof createClient>,
|
||||||
senderSid: string,
|
senderSid: string,
|
||||||
|
|
@ -1013,6 +1117,14 @@ async function updateWebhook(
|
||||||
if (globalVerbose) console.error("Incoming number update failed", err);
|
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(danger("Failed to set Twilio webhook."));
|
||||||
console.error(
|
console.error(
|
||||||
info(
|
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
|
program
|
||||||
.name("warelay")
|
.name("warelay")
|
||||||
.description("WhatsApp relay CLI using Twilio")
|
.description("WhatsApp relay CLI using Twilio")
|
||||||
|
|
@ -1167,6 +1368,46 @@ Examples:
|
||||||
await monitor(intervalSeconds, lookbackMinutes);
|
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
|
program
|
||||||
.command("poll")
|
.command("poll")
|
||||||
.description("Poll Twilio for inbound WhatsApp messages (non-webhook mode)")
|
.description("Poll Twilio for inbound WhatsApp messages (non-webhook mode)")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue