mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-02-01 03:47:45 +01:00
chore: drop twilio and go web-only
This commit is contained in:
@@ -386,11 +386,11 @@ export async function runCommandReply(
|
||||
let rpcInput: string | undefined;
|
||||
let rpcArgv = finalArgv;
|
||||
if (agentKind === "pi") {
|
||||
rpcInput = JSON.stringify({ type: "prompt", message: promptArg }) + "\n";
|
||||
rpcInput = `${JSON.stringify({ type: "prompt", message: promptArg })}\n`;
|
||||
const bodyIdx =
|
||||
promptIndex >= 0 ? promptIndex : Math.max(finalArgv.length - 1, 0);
|
||||
rpcArgv = finalArgv.filter((_, idx) => idx !== bodyIdx);
|
||||
const modeIdx = rpcArgv.findIndex((v) => v === "--mode");
|
||||
const modeIdx = rpcArgv.indexOf("--mode");
|
||||
if (modeIdx >= 0 && rpcArgv[modeIdx + 1]) {
|
||||
rpcArgv[modeIdx + 1] = "rpc";
|
||||
} else {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WarelayConfig } from "../config/config.js";
|
||||
import { autoReplyIfConfigured } from "./reply.js";
|
||||
|
||||
describe("autoReplyIfConfigured chunking", () => {
|
||||
it("sends a single Twilio message for multi-line text under limit", async () => {
|
||||
const body = [
|
||||
"Oh! Hi Peter! 🦞",
|
||||
"",
|
||||
"Sorry, I got a bit trigger-happy with the heartbeat response there. What's up?",
|
||||
"",
|
||||
"Everything working on your end?",
|
||||
].join("\n");
|
||||
|
||||
const config: WarelayConfig = {
|
||||
inbound: {
|
||||
reply: {
|
||||
mode: "text",
|
||||
text: body,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const create = vi.fn().mockResolvedValue({});
|
||||
const client = { messages: { create } } as unknown as Parameters<
|
||||
typeof autoReplyIfConfigured
|
||||
>[0];
|
||||
|
||||
const message = {
|
||||
body: "ping",
|
||||
from: "+15551234567",
|
||||
to: "+15557654321",
|
||||
sid: "SM123",
|
||||
} as Parameters<typeof autoReplyIfConfigured>[1];
|
||||
|
||||
await autoReplyIfConfigured(client, message, config);
|
||||
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body,
|
||||
from: message.to,
|
||||
to: message.from,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
import { loadConfig, type WarelayConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
@@ -10,14 +9,10 @@ import {
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { info, isVerbose, logVerbose } from "../globals.js";
|
||||
import { isVerbose, logVerbose } from "../globals.js";
|
||||
import { triggerWarelayRestart } from "../infra/restart.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import type { TwilioRequester } from "../twilio/types.js";
|
||||
import { sendTypingIndicator } from "../twilio/typing.js";
|
||||
import { chunkText } from "./chunk.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { runCommandReply } from "./command-reply.js";
|
||||
import {
|
||||
applyTemplate,
|
||||
@@ -35,8 +30,6 @@ import type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||
|
||||
export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||
|
||||
const TWILIO_TEXT_LIMIT = 1600;
|
||||
|
||||
const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
|
||||
const ABORT_MEMORY = new Map<string, boolean>();
|
||||
|
||||
@@ -193,7 +186,7 @@ export async function getReplyFromConfig(
|
||||
1,
|
||||
);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const storePath = sessionCfg ? resolveStorePath(sessionCfg.store) : undefined;
|
||||
let sessionStore: ReturnType<typeof loadSessionStore> | undefined;
|
||||
let sessionKey: string | undefined;
|
||||
let sessionEntry: SessionEntry | undefined;
|
||||
@@ -693,162 +686,3 @@ export async function getReplyFromConfig(
|
||||
cleanupTyping();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type TwilioLikeClient = TwilioRequester & {
|
||||
messages: {
|
||||
create: (opts: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
body: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function autoReplyIfConfigured(
|
||||
client: TwilioLikeClient,
|
||||
message: MessageInstance,
|
||||
configOverride?: WarelayConfig,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<void> {
|
||||
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||
const ctx: MsgContext = {
|
||||
Body: message.body ?? undefined,
|
||||
From: message.from ?? undefined,
|
||||
To: message.to ?? undefined,
|
||||
MessageSid: message.sid,
|
||||
};
|
||||
const replyFrom = message.to;
|
||||
const replyTo = message.from;
|
||||
if (!replyFrom || !replyTo) {
|
||||
if (isVerbose())
|
||||
console.error(
|
||||
"Skipping auto-reply: missing to/from on inbound message",
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
// Attach media hints for transcription/templates if present on Twilio payloads.
|
||||
const mediaUrl = (message as { mediaUrl?: string }).mediaUrl;
|
||||
if (mediaUrl) ctx.MediaUrl = mediaUrl;
|
||||
|
||||
// Optional audio transcription before building reply.
|
||||
const mediaField = (message as { media?: unknown }).media;
|
||||
const mediaItems = Array.isArray(mediaField) ? mediaField : [];
|
||||
if (cfg.inbound?.transcribeAudio && mediaItems.length) {
|
||||
const media = mediaItems[0];
|
||||
const contentType = (media as { contentType?: string }).contentType;
|
||||
if (contentType?.startsWith("audio")) {
|
||||
const transcribed = await transcribeInboundAudio(cfg, ctx, runtime);
|
||||
if (transcribed?.text) {
|
||||
ctx.Body = transcribed.text;
|
||||
ctx.MediaType = contentType;
|
||||
logVerbose("Replaced Body with audio transcript for reply flow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendTwilio = async (body: string, media?: string) => {
|
||||
let resolvedMedia = media;
|
||||
if (resolvedMedia && !/^https?:\/\//i.test(resolvedMedia)) {
|
||||
const hosted = await ensureMediaHosted(resolvedMedia);
|
||||
resolvedMedia = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: replyFrom,
|
||||
to: replyTo,
|
||||
body,
|
||||
...(resolvedMedia ? { mediaUrl: [resolvedMedia] } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const sendPayload = async (replyPayload: ReplyPayload) => {
|
||||
const mediaList = replyPayload.mediaUrls?.length
|
||||
? replyPayload.mediaUrls
|
||||
: replyPayload.mediaUrl
|
||||
? [replyPayload.mediaUrl]
|
||||
: [];
|
||||
|
||||
const text = replyPayload.text ?? "";
|
||||
const chunks = chunkText(text, TWILIO_TEXT_LIMIT);
|
||||
if (chunks.length === 0) chunks.push("");
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const body = chunks[i];
|
||||
const attachMedia = i === 0 ? mediaList[0] : undefined;
|
||||
|
||||
if (body) {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${body.length}`,
|
||||
);
|
||||
} else if (attachMedia) {
|
||||
logVerbose(
|
||||
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media only)`,
|
||||
);
|
||||
}
|
||||
|
||||
await sendTwilio(body, attachMedia);
|
||||
|
||||
if (i === 0 && mediaList.length > 1) {
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
await sendTwilio("", extra);
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
info(
|
||||
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${attachMedia ? ", media" : ""})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const partialSender = async (payload: ReplyPayload) => {
|
||||
await sendPayload(payload);
|
||||
};
|
||||
|
||||
const replyResult = await getReplyFromConfig(
|
||||
ctx,
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, message.sid),
|
||||
onPartialReply: partialSender,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
const replies = replyResult
|
||||
? Array.isArray(replyResult)
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
if (replies.length === 0) return;
|
||||
|
||||
try {
|
||||
for (const replyPayload of replies) {
|
||||
await sendPayload(replyPayload);
|
||||
}
|
||||
} 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);
|
||||
runtime.error(
|
||||
`❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`,
|
||||
);
|
||||
if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`);
|
||||
const responseBody = anyErr?.response?.body;
|
||||
if (responseBody) {
|
||||
runtime.error("Response body:");
|
||||
runtime.error(JSON.stringify(responseBody, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +1,13 @@
|
||||
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
|
||||
import { readEnv } from "../env.js";
|
||||
import { info } from "../globals.js";
|
||||
import { ensureBinary } from "../infra/binaries.js";
|
||||
import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
|
||||
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
|
||||
import { ensureMediaHosted } from "../media/host.js";
|
||||
import {
|
||||
logWebSelfId,
|
||||
monitorWebProvider,
|
||||
sendMessageWeb,
|
||||
} from "../providers/web/index.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { createClient } from "../twilio/client.js";
|
||||
import { listRecentMessages } from "../twilio/messages.js";
|
||||
import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js";
|
||||
import { sendMessage, waitForFinalStatus } from "../twilio/send.js";
|
||||
import { findWhatsappSenderSid } from "../twilio/senders.js";
|
||||
import { assertProvider, sleep } from "../utils.js";
|
||||
import { startWebhook } from "../webhook/server.js";
|
||||
import { updateWebhook } from "../webhook/update.js";
|
||||
import { waitForever } from "./wait.js";
|
||||
import { logWebSelfId, sendMessageWeb } from "../providers/web/index.js";
|
||||
|
||||
export type CliDeps = {
|
||||
sendMessage: typeof sendMessage;
|
||||
sendMessageWeb: typeof sendMessageWeb;
|
||||
waitForFinalStatus: typeof waitForFinalStatus;
|
||||
assertProvider: typeof assertProvider;
|
||||
createClient?: typeof createClient;
|
||||
monitorTwilio: typeof monitorTwilio;
|
||||
listRecentMessages: typeof listRecentMessages;
|
||||
ensurePortAvailable: typeof ensurePortAvailable;
|
||||
startWebhook: typeof startWebhook;
|
||||
waitForever: typeof waitForever;
|
||||
ensureBinary: typeof ensureBinary;
|
||||
ensureFunnel: typeof ensureFunnel;
|
||||
getTailnetHostname: typeof getTailnetHostname;
|
||||
readEnv: typeof readEnv;
|
||||
findWhatsappSenderSid: typeof findWhatsappSenderSid;
|
||||
updateWebhook: typeof updateWebhook;
|
||||
handlePortError: typeof handlePortError;
|
||||
monitorWebProvider: typeof monitorWebProvider;
|
||||
resolveTwilioMediaUrl: (
|
||||
source: string,
|
||||
opts: { serveMedia: boolean; runtime: RuntimeEnv },
|
||||
) => Promise<string>;
|
||||
};
|
||||
|
||||
export async function monitorTwilio(
|
||||
intervalSeconds: number,
|
||||
lookbackMinutes: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
maxIterations = Infinity,
|
||||
) {
|
||||
// Adapter that wires default deps/runtime for the Twilio monitor loop.
|
||||
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
|
||||
client: clientOverride,
|
||||
maxIterations,
|
||||
deps: {
|
||||
autoReplyIfConfigured,
|
||||
listRecentMessages,
|
||||
readEnv,
|
||||
createClient,
|
||||
sleep,
|
||||
},
|
||||
runtime: defaultRuntime,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDefaultDeps(): CliDeps {
|
||||
// Default dependency bundle used by CLI commands and tests.
|
||||
return {
|
||||
sendMessage,
|
||||
sendMessageWeb,
|
||||
waitForFinalStatus,
|
||||
assertProvider,
|
||||
createClient,
|
||||
monitorTwilio,
|
||||
listRecentMessages,
|
||||
ensurePortAvailable,
|
||||
startWebhook,
|
||||
waitForever,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
getTailnetHostname,
|
||||
readEnv,
|
||||
findWhatsappSenderSid,
|
||||
updateWebhook,
|
||||
handlePortError,
|
||||
monitorWebProvider,
|
||||
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
|
||||
if (/^https?:\/\//i.test(source)) return source;
|
||||
const hosted = await ensureMediaHosted(source, {
|
||||
startServer: serveMedia,
|
||||
runtime,
|
||||
});
|
||||
return hosted.url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
|
||||
// Log the configured Twilio sender for clarity in CLI output.
|
||||
const env = readEnv(runtime);
|
||||
runtime.log(
|
||||
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
|
||||
);
|
||||
}
|
||||
|
||||
export { logWebSelfId };
|
||||
|
||||
@@ -2,16 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendCommand = vi.fn();
|
||||
const statusCommand = vi.fn();
|
||||
const webhookCommand = vi.fn().mockResolvedValue(undefined);
|
||||
const ensureTwilioEnv = vi.fn();
|
||||
const loginWeb = vi.fn();
|
||||
const monitorWebProvider = vi.fn();
|
||||
const pickProvider = vi.fn();
|
||||
const monitorTwilio = vi.fn();
|
||||
const logTwilioFrom = vi.fn();
|
||||
const logWebSelfId = vi.fn();
|
||||
const waitForever = vi.fn();
|
||||
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
|
||||
const spawnRelayTmux = vi.fn().mockResolvedValue("clawdis-relay");
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
@@ -23,19 +18,14 @@ const runtime = {
|
||||
|
||||
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||
vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
|
||||
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
|
||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||
vi.mock("../provider-web.js", () => ({
|
||||
loginWeb,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
}));
|
||||
vi.mock("./deps.js", () => ({
|
||||
createDefaultDeps: () => ({ waitForever }),
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
}));
|
||||
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
|
||||
|
||||
@@ -54,55 +44,15 @@ describe("cli program", () => {
|
||||
expect(sendCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid relay provider", async () => {
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
|
||||
).rejects.toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"--provider must be auto, web, or twilio",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to twilio when web relay fails", async () => {
|
||||
pickProvider.mockResolvedValue("web");
|
||||
monitorWebProvider.mockRejectedValue(new Error("no web"));
|
||||
const program = buildProgram();
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("exit");
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(ensureTwilioEnv).not.toHaveBeenCalled();
|
||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs relay tmux attach command", async () => {
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||
true;
|
||||
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||
"pnpm clawdis relay --verbose",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||
originalIsTTY;
|
||||
});
|
||||
|
||||
it("runs relay heartbeat command", async () => {
|
||||
pickProvider.mockResolvedValue("web");
|
||||
it("starts relay with heartbeat tuning", async () => {
|
||||
monitorWebProvider.mockResolvedValue(undefined);
|
||||
const originalExit = runtime.exit;
|
||||
runtime.exit = vi.fn();
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:heartbeat"], { from: "user" });
|
||||
await program.parseAsync(
|
||||
["relay", "--web-heartbeat", "90", "--heartbeat-now"],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(monitorWebProvider).toHaveBeenCalledWith(
|
||||
false,
|
||||
@@ -111,8 +61,17 @@ describe("cli program", () => {
|
||||
undefined,
|
||||
runtime,
|
||||
undefined,
|
||||
{ replyHeartbeatNow: true },
|
||||
{ heartbeatSeconds: 90, replyHeartbeatNow: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("runs relay heartbeat command", async () => {
|
||||
monitorWebProvider.mockResolvedValue(undefined);
|
||||
const originalExit = runtime.exit;
|
||||
runtime.exit = vi.fn();
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["relay:heartbeat"], { from: "user" });
|
||||
expect(logWebSelfId).toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
runtime.exit = originalExit;
|
||||
});
|
||||
@@ -126,4 +85,10 @@ describe("cli program", () => {
|
||||
shouldAttach,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs status command", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["status"], { from: "user" });
|
||||
expect(statusCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,45 +3,35 @@ import { Command } from "commander";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { sendCommand } from "../commands/send.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { webhookCommand } from "../commands/webhook.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { ensureTwilioEnv } from "../env.js";
|
||||
import { danger, info, setVerbose, setYes } from "../globals.js";
|
||||
import { danger, info, setVerbose } from "../globals.js";
|
||||
import { getResolvedLoggerSettings } from "../logging.js";
|
||||
import {
|
||||
loginWeb,
|
||||
logoutWeb,
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
resolveHeartbeatRecipients,
|
||||
runWebHeartbeatOnce,
|
||||
type WebMonitorTuning,
|
||||
} from "../provider-web.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js";
|
||||
import type { Provider } from "../utils.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
resolveHeartbeatSeconds,
|
||||
resolveReconnectPolicy,
|
||||
} from "../web/reconnect.js";
|
||||
import {
|
||||
createDefaultDeps,
|
||||
logTwilioFrom,
|
||||
logWebSelfId,
|
||||
monitorTwilio,
|
||||
} from "./deps.js";
|
||||
import { createDefaultDeps, logWebSelfId } from "./deps.js";
|
||||
import { spawnRelayTmux } from "./relay_tmux.js";
|
||||
|
||||
export function buildProgram() {
|
||||
const program = new Command();
|
||||
const PROGRAM_VERSION = VERSION;
|
||||
const TAGLINE =
|
||||
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
|
||||
"Send, receive, and auto-reply on WhatsApp—Baileys (web) only.";
|
||||
|
||||
program
|
||||
.name("clawdis")
|
||||
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
|
||||
.description("WhatsApp relay CLI (WhatsApp Web session only)")
|
||||
.version(PROGRAM_VERSION);
|
||||
|
||||
const formatIntroLine = (version: string, rich = true) => {
|
||||
@@ -80,24 +70,24 @@ export function buildProgram() {
|
||||
"Link personal WhatsApp Web and show QR + connection logs.",
|
||||
],
|
||||
[
|
||||
'clawdis send --to +15551234567 --message "Hi" --provider web --json',
|
||||
'clawdis send --to +15551234567 --message "Hi" --json',
|
||||
"Send via your web session and print JSON result.",
|
||||
],
|
||||
[
|
||||
"clawdis relay --provider auto --interval 5 --lookback 15 --verbose",
|
||||
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
|
||||
"clawdis relay --verbose",
|
||||
"Auto-reply loop using your linked web session.",
|
||||
],
|
||||
[
|
||||
"clawdis webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
|
||||
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
|
||||
"clawdis heartbeat --verbose",
|
||||
"Send a heartbeat ping to your active session or first allowFrom contact.",
|
||||
],
|
||||
[
|
||||
"clawdis status --limit 10 --lookback 60 --json",
|
||||
"Show last 10 messages from the past hour as JSON.",
|
||||
"clawdis status",
|
||||
"Show web session health and recent session recipients.",
|
||||
],
|
||||
[
|
||||
'clawdis agent --to +15551234567 --message "Run summary" --thinking high',
|
||||
"Talk directly to the agent using the same session handling, no WhatsApp send.",
|
||||
'clawdis agent --to +15551234567 --message "Run summary" --deliver',
|
||||
"Talk directly to the agent using the same session handling; optionally send the reply.",
|
||||
],
|
||||
] as const;
|
||||
|
||||
@@ -138,7 +128,7 @@ export function buildProgram() {
|
||||
|
||||
program
|
||||
.command("send")
|
||||
.description("Send a WhatsApp message")
|
||||
.description("Send a WhatsApp message (web provider)")
|
||||
.requiredOption(
|
||||
"-t, --to <number>",
|
||||
"Recipient number in E.164 (e.g. +15551234567)",
|
||||
@@ -146,20 +136,8 @@ export function buildProgram() {
|
||||
.requiredOption("-m, --message <text>", "Message body")
|
||||
.option(
|
||||
"--media <path-or-url>",
|
||||
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
|
||||
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
||||
)
|
||||
.option(
|
||||
"--serve-media",
|
||||
"For Twilio: start a temporary media server if webhook is not running",
|
||||
false,
|
||||
)
|
||||
.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")
|
||||
.option("--dry-run", "Print payload and skip sending", false)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
@@ -167,10 +145,10 @@ export function buildProgram() {
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
clawdis send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
|
||||
clawdis send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
|
||||
clawdis send --to +15551234567 --message "Hi"
|
||||
clawdis send --to +15551234567 --message "Hi" --media photo.jpg
|
||||
clawdis send --to +15551234567 --message "Hi" --dry-run # print payload only
|
||||
clawdis send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
|
||||
clawdis send --to +15551234567 --message "Hi" --json # machine-readable result`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
@@ -204,11 +182,6 @@ Examples:
|
||||
"Send the agent's reply back to WhatsApp (requires --to)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Provider to deliver via when using --deliver (auto | web | twilio)",
|
||||
"auto",
|
||||
)
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option(
|
||||
"--timeout <seconds>",
|
||||
@@ -221,7 +194,7 @@ Examples:
|
||||
clawdis agent --to +15551234567 --message "status update"
|
||||
clawdis agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||
clawdis agent --to +15551234567 --message "Trace logs" --verbose on --json
|
||||
clawdis agent --to +15551234567 --message "Summon reply" --deliver --provider web
|
||||
clawdis agent --to +15551234567 --message "Summon reply" --deliver
|
||||
`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
@@ -240,10 +213,7 @@ Examples:
|
||||
|
||||
program
|
||||
.command("heartbeat")
|
||||
.description(
|
||||
"Trigger a heartbeat or manual send once (web or twilio, no tmux)",
|
||||
)
|
||||
.option("--provider <provider>", "auto | web | twilio", "auto")
|
||||
.description("Trigger a heartbeat or manual send once (web only, no tmux)")
|
||||
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
||||
.option(
|
||||
"--session-id <id>",
|
||||
@@ -256,7 +226,7 @@ Examples:
|
||||
)
|
||||
.option(
|
||||
"--message <text>",
|
||||
"Send a custom message instead of the heartbeat probe (web or twilio provider)",
|
||||
"Send a custom message instead of the heartbeat probe",
|
||||
)
|
||||
.option("--body <text>", "Alias for --message")
|
||||
.option("--dry-run", "Print the resolved payload without sending", false)
|
||||
@@ -269,7 +239,7 @@ Examples:
|
||||
clawdis heartbeat --verbose # prints detailed heartbeat logs
|
||||
clawdis heartbeat --to +1555123 # override destination
|
||||
clawdis heartbeat --session-id <uuid> --to +1555123 # resume a specific session
|
||||
clawdis heartbeat --message "Ping" --provider twilio
|
||||
clawdis heartbeat --message "Ping"
|
||||
clawdis heartbeat --all # send to every active session recipient or allowFrom entry`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
@@ -302,11 +272,6 @@ Examples:
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
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 overrideBody =
|
||||
(opts.message as string | undefined) ||
|
||||
@@ -314,32 +279,16 @@ Examples:
|
||||
undefined;
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
|
||||
const provider =
|
||||
providerPref === "twilio"
|
||||
? "twilio"
|
||||
: await pickProvider(providerPref as "auto" | "web");
|
||||
if (provider === "twilio") ensureTwilioEnv();
|
||||
|
||||
try {
|
||||
for (const to of recipients) {
|
||||
if (provider === "web") {
|
||||
await runWebHeartbeatOnce({
|
||||
to,
|
||||
verbose: Boolean(opts.verbose),
|
||||
runtime: defaultRuntime,
|
||||
sessionId: opts.sessionId,
|
||||
overrideBody,
|
||||
dryRun,
|
||||
});
|
||||
} else {
|
||||
await runTwilioHeartbeatOnce({
|
||||
to,
|
||||
verbose: Boolean(opts.verbose),
|
||||
runtime: defaultRuntime,
|
||||
overrideBody,
|
||||
dryRun,
|
||||
});
|
||||
}
|
||||
await runWebHeartbeatOnce({
|
||||
to,
|
||||
verbose: Boolean(opts.verbose),
|
||||
runtime: defaultRuntime,
|
||||
sessionId: opts.sessionId,
|
||||
overrideBody,
|
||||
dryRun,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
defaultRuntime.exit(1);
|
||||
@@ -348,14 +297,7 @@ Examples:
|
||||
|
||||
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",
|
||||
)
|
||||
.description("Auto-reply to inbound messages (web only)")
|
||||
.option(
|
||||
"--web-heartbeat <seconds>",
|
||||
"Heartbeat interval for web relay health logs (seconds)",
|
||||
@@ -371,7 +313,7 @@ Examples:
|
||||
.option("--web-retry-max <ms>", "Max reconnect backoff for web relay (ms)")
|
||||
.option(
|
||||
"--heartbeat-now",
|
||||
"Run a heartbeat immediately when relay starts (web provider)",
|
||||
"Run a heartbeat immediately when relay starts",
|
||||
false,
|
||||
)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
@@ -379,10 +321,8 @@ Examples:
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
clawdis relay # auto: web if logged-in, else twilio poll
|
||||
clawdis relay --provider web # force personal web session
|
||||
clawdis relay --provider twilio # force twilio poll
|
||||
clawdis relay --provider twilio --interval 2 --lookback 30
|
||||
clawdis relay # uses your linked web session
|
||||
clawdis relay --web-heartbeat 60 # override heartbeat interval
|
||||
# Troubleshooting: docs/refactor/web-relay-troubleshooting.md
|
||||
`,
|
||||
)
|
||||
@@ -390,13 +330,6 @@ Examples:
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
|
||||
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
|
||||
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);
|
||||
const webHeartbeat =
|
||||
opts.webHeartbeat !== undefined
|
||||
? Number.parseInt(String(opts.webHeartbeat), 10)
|
||||
@@ -414,14 +347,6 @@ Examples:
|
||||
? Number.parseInt(String(opts.webRetryMax), 10)
|
||||
: undefined;
|
||||
const heartbeatNow = Boolean(opts.heartbeatNow);
|
||||
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);
|
||||
}
|
||||
if (
|
||||
webHeartbeat !== undefined &&
|
||||
(Number.isNaN(webHeartbeat) || webHeartbeat <= 0)
|
||||
@@ -469,49 +394,37 @@ Examples:
|
||||
if (Object.keys(reconnect).length > 0) {
|
||||
webTuning.reconnect = reconnect;
|
||||
}
|
||||
|
||||
const provider = await pickProvider(providerPref as Provider | "auto");
|
||||
|
||||
if (provider === "web") {
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
const cfg = loadConfig();
|
||||
const effectiveHeartbeat = resolveHeartbeatSeconds(
|
||||
cfg,
|
||||
webTuning.heartbeatSeconds,
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
const cfg = loadConfig();
|
||||
const effectiveHeartbeat = resolveHeartbeatSeconds(
|
||||
cfg,
|
||||
webTuning.heartbeatSeconds,
|
||||
);
|
||||
const effectivePolicy = resolveReconnectPolicy(cfg, webTuning.reconnect);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await monitorWebProvider(
|
||||
Boolean(opts.verbose),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
defaultRuntime,
|
||||
undefined,
|
||||
webTuning,
|
||||
);
|
||||
const effectivePolicy = resolveReconnectPolicy(
|
||||
cfg,
|
||||
webTuning.reconnect,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
|
||||
return;
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Web relay failed: ${String(err)}. Re-link with 'clawdis login --verbose'.`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await monitorWebProvider(
|
||||
Boolean(opts.verbose),
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
defaultRuntime,
|
||||
undefined,
|
||||
webTuning,
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
`Web relay failed: ${String(err)}. Not falling back; re-link with 'clawdis login --provider web'.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
ensureTwilioEnv();
|
||||
logTwilioFrom();
|
||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -519,28 +432,11 @@ Examples:
|
||||
.description(
|
||||
"Run relay with an immediate heartbeat (no tmux); requires web provider",
|
||||
)
|
||||
.option("--provider <provider>", "auto | web", "auto")
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const { file: logFile, level: logLevel } = getResolvedLoggerSettings();
|
||||
defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`));
|
||||
const providerPref = String(opts.provider ?? "auto");
|
||||
if (!["auto", "web"].includes(providerPref)) {
|
||||
defaultRuntime.error("--provider must be auto or web");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const provider = await pickProvider(providerPref as "auto" | "web");
|
||||
if (provider !== "web") {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"Heartbeat relay is only supported for the web provider. Link with `clawdis login --verbose`.",
|
||||
),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
logWebSelfId(defaultRuntime, true);
|
||||
const cfg = loadConfig();
|
||||
@@ -574,75 +470,20 @@ Examples:
|
||||
|
||||
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")
|
||||
.description("Show web session health and recent session recipients")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
clawdis status # last 20 msgs in past 4h
|
||||
clawdis status --limit 5 --lookback 30 # last 5 msgs in past 30m
|
||||
clawdis status --json --limit 50 # machine-readable output`,
|
||||
clawdis status # show linked account + session store summary
|
||||
clawdis status --json # machine-readable output`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await statusCommand(opts, deps, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("webhook")
|
||||
.description(
|
||||
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
|
||||
)
|
||||
.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(
|
||||
"--ingress <mode>",
|
||||
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
|
||||
"tailscale",
|
||||
)
|
||||
.option("--verbose", "Log inbound and auto-replies", false)
|
||||
.option("-y, --yes", "Auto-confirm prompts when possible", false)
|
||||
.option("--dry-run", "Print planned actions without starting server", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
clawdis webhook # ingress=tailscale (funnel + Twilio update)
|
||||
clawdis webhook --ingress none # local-only server (no funnel / no Twilio update)
|
||||
clawdis webhook --port 45000 # pick a high, less-colliding port
|
||||
clawdis webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
|
||||
)
|
||||
// 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);
|
||||
if (!server) {
|
||||
defaultRuntime.log(
|
||||
info("Webhook dry-run complete; no server started."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.on("SIGINT", () => {
|
||||
server.close(() => {
|
||||
console.log("\n👋 Webhook stopped");
|
||||
defaultRuntime.exit(0);
|
||||
});
|
||||
});
|
||||
await deps.waitForever();
|
||||
await statusCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
|
||||
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
|
||||
vi.hoisted(() => {
|
||||
return {
|
||||
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
|
||||
pickProvider: vi.fn().mockResolvedValue("web"),
|
||||
logWebSelfId: vi.fn(),
|
||||
monitorTwilio: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../provider-web.js", () => ({
|
||||
monitorWebProvider,
|
||||
pickProvider,
|
||||
logWebSelfId,
|
||||
}));
|
||||
|
||||
vi.mock("../twilio/monitor.js", () => ({
|
||||
monitorTwilio,
|
||||
}));
|
||||
|
||||
import { buildProgram } from "./program.js";
|
||||
|
||||
describe("CLI relay command (e2e-ish)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("runs relay in web mode without crashing", async () => {
|
||||
const program = buildProgram();
|
||||
program.exitOverride(); // throw instead of exiting process on error
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
|
||||
).resolves.toBeInstanceOf(Object);
|
||||
|
||||
expect(pickProvider).toHaveBeenCalledWith("web");
|
||||
expect(logWebSelfId).toHaveBeenCalledTimes(1);
|
||||
expect(monitorWebProvider).toHaveBeenCalledTimes(1);
|
||||
expect(monitorWebProvider.mock.calls[0][0]).toBe(false);
|
||||
expect(monitorTwilio).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import { runCommandReply } from "../auto-reply/command-reply.js";
|
||||
import {
|
||||
applyTemplate,
|
||||
@@ -22,11 +21,8 @@ import {
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { ensureTwilioEnv } from "../env.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { pickProvider } from "../provider-web.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import type { Provider } from "../utils.js";
|
||||
import { sendViaIpc } from "../web/ipc.js";
|
||||
|
||||
type AgentCommandOpts = {
|
||||
@@ -38,7 +34,6 @@ type AgentCommandOpts = {
|
||||
json?: boolean;
|
||||
timeout?: string;
|
||||
deliver?: boolean;
|
||||
provider?: Provider | "auto";
|
||||
};
|
||||
|
||||
type SessionResolution = {
|
||||
@@ -344,14 +339,6 @@ export async function agentCommand(
|
||||
}
|
||||
|
||||
const deliver = opts.deliver === true;
|
||||
let provider: Provider | "auto" | undefined = opts.provider ?? "auto";
|
||||
if (deliver) {
|
||||
provider =
|
||||
provider === "twilio"
|
||||
? "twilio"
|
||||
: await pickProvider((provider ?? "auto") as Provider | "auto");
|
||||
if (provider === "twilio") ensureTwilioEnv();
|
||||
}
|
||||
|
||||
for (const payload of payloads) {
|
||||
const lines: string[] = [];
|
||||
@@ -366,55 +353,29 @@ export async function agentCommand(
|
||||
if (deliver && opts.to) {
|
||||
const text = payload.text ?? "";
|
||||
const media = mediaList;
|
||||
if (provider === "web") {
|
||||
// Prefer IPC to reuse the running relay; fall back to direct web send.
|
||||
let sentViaIpc = false;
|
||||
const ipcResult = await sendViaIpc(opts.to, text, media[0]);
|
||||
if (ipcResult) {
|
||||
sentViaIpc = ipcResult.success;
|
||||
if (ipcResult.success && media.length > 1) {
|
||||
for (const extra of media.slice(1)) {
|
||||
await sendViaIpc(opts.to, "", extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sentViaIpc) {
|
||||
if (text || media.length === 0) {
|
||||
await deps.sendMessageWeb(opts.to, text, {
|
||||
verbose: false,
|
||||
mediaUrl: media[0],
|
||||
});
|
||||
}
|
||||
// Prefer IPC to reuse the running relay; fall back to direct web send.
|
||||
let sentViaIpc = false;
|
||||
const ipcResult = await sendViaIpc(opts.to, text, media[0]);
|
||||
if (ipcResult) {
|
||||
sentViaIpc = ipcResult.success;
|
||||
if (ipcResult.success && media.length > 1) {
|
||||
for (const extra of media.slice(1)) {
|
||||
await deps.sendMessageWeb(opts.to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: extra,
|
||||
});
|
||||
await sendViaIpc(opts.to, "", extra);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const chunks = chunkText(text, 1600);
|
||||
const resolvedMedia = await Promise.all(
|
||||
media.map((m) =>
|
||||
deps.resolveTwilioMediaUrl(m, { serveMedia: false, runtime }),
|
||||
),
|
||||
);
|
||||
const firstMedia = resolvedMedia[0];
|
||||
if (chunks.length === 0) chunks.push("");
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const bodyChunk = chunks[i];
|
||||
const attach = i === 0 ? firstMedia : undefined;
|
||||
await deps.sendMessage(
|
||||
opts.to,
|
||||
bodyChunk,
|
||||
{ mediaUrl: attach },
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
if (!sentViaIpc) {
|
||||
if (text || media.length === 0) {
|
||||
await deps.sendMessageWeb(opts.to, text, {
|
||||
verbose: false,
|
||||
mediaUrl: media[0],
|
||||
});
|
||||
}
|
||||
if (resolvedMedia.length > 1) {
|
||||
for (const extra of resolvedMedia.slice(1)) {
|
||||
await deps.sendMessage(opts.to, "", { mediaUrl: extra }, runtime);
|
||||
}
|
||||
for (const extra of media.slice(1)) {
|
||||
await deps.sendMessageWeb(opts.to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { CliDeps } from "../cli/deps.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { sendCommand } from "./send.js";
|
||||
|
||||
const sendViaIpcMock = vi.fn().mockResolvedValue(null);
|
||||
vi.mock("../web/ipc.js", () => ({
|
||||
sendViaIpc: vi.fn().mockResolvedValue(null),
|
||||
sendViaIpc: (...args: unknown[]) => sendViaIpcMock(...args),
|
||||
}));
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
@@ -16,59 +17,19 @@ const runtime: RuntimeEnv = {
|
||||
}),
|
||||
};
|
||||
|
||||
const baseDeps = {
|
||||
assertProvider: vi.fn(),
|
||||
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
|
||||
sendMessageWeb: vi.fn(),
|
||||
resolveTwilioMediaUrl: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as unknown as CliDeps;
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("sendCommand", () => {
|
||||
it("validates wait and poll", async () => {
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "-1",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "0",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Poll must be > 0 seconds");
|
||||
});
|
||||
|
||||
it("handles web dry-run and warns on wait", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn(),
|
||||
} as CliDeps;
|
||||
it("skips send on dry-run", async () => {
|
||||
const deps = makeDeps();
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "5",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
dryRun: true,
|
||||
media: "pic.jpg",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
@@ -76,74 +37,54 @@ describe("sendCommand", () => {
|
||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends via web and outputs JSON", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||
} as CliDeps;
|
||||
it("uses IPC when available", async () => {
|
||||
sendViaIpcMock.mockResolvedValueOnce({ success: true, messageId: "ipc1" });
|
||||
const deps = makeDeps();
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "1",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("ipc1"));
|
||||
});
|
||||
|
||||
it("falls back to direct send when IPC fails", async () => {
|
||||
sendViaIpcMock.mockResolvedValueOnce({ success: false, error: "nope" });
|
||||
const deps = makeDeps({
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "direct1" }),
|
||||
});
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
media: "pic.jpg",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "web"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports twilio dry-run", async () => {
|
||||
const deps = { ...baseDeps } as CliDeps;
|
||||
it("emits json output", async () => {
|
||||
sendViaIpcMock.mockResolvedValueOnce(null);
|
||||
const deps = makeDeps({
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "direct2" }),
|
||||
});
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
dryRun: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends via twilio with media and skips wait when zero", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
media: "pic.jpg",
|
||||
serveMedia: true,
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||
serveMedia: true,
|
||||
runtime,
|
||||
});
|
||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "twilio"'),
|
||||
expect.stringContaining('"provider": "web"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,148 +1,81 @@
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { info, success } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { Provider } from "../utils.js";
|
||||
import { sendViaIpc } from "../web/ipc.js";
|
||||
|
||||
export async function sendCommand(
|
||||
opts: {
|
||||
to: string;
|
||||
message: string;
|
||||
wait: string;
|
||||
poll: string;
|
||||
provider: Provider;
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
media?: string;
|
||||
serveMedia?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
deps.assertProvider(opts.provider);
|
||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||
|
||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||
throw new Error("Wait must be >= 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||
throw new Error("Poll must be > 0 seconds");
|
||||
}
|
||||
|
||||
if (opts.provider === "web") {
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (waitSeconds !== 0) {
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
|
||||
// Try to send via IPC to running relay first (avoids Signal session corruption)
|
||||
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
|
||||
if (ipcResult) {
|
||||
if (ipcResult.success) {
|
||||
runtime.log(
|
||||
success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`),
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
via: "ipc",
|
||||
to: opts.to,
|
||||
messageId: ipcResult.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// IPC failed but relay is running - warn and fall back
|
||||
runtime.log(
|
||||
info(
|
||||
`IPC send failed (${ipcResult.error}), falling back to direct connection`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fall back to direct connection (creates new Baileys socket)
|
||||
const res = await deps
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
via: "direct",
|
||||
to: opts.to,
|
||||
messageId: res.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaUrl: string | undefined;
|
||||
if (opts.media) {
|
||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||
serveMedia: Boolean(opts.serveMedia),
|
||||
runtime,
|
||||
});
|
||||
// Try to send via IPC to running relay first (avoids Signal session corruption)
|
||||
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
|
||||
if (ipcResult) {
|
||||
if (ipcResult.success) {
|
||||
runtime.log(
|
||||
success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`),
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
via: "ipc",
|
||||
to: opts.to,
|
||||
messageId: ipcResult.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// IPC failed but relay is running - warn and fall back
|
||||
runtime.log(
|
||||
info(
|
||||
`IPC send failed (${ipcResult.error}), falling back to direct connection`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await deps.sendMessage(
|
||||
opts.to,
|
||||
opts.message,
|
||||
{ mediaUrl },
|
||||
runtime,
|
||||
);
|
||||
// Fall back to direct connection (creates new Baileys socket)
|
||||
const res = await deps
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "twilio",
|
||||
provider: "web",
|
||||
via: "direct",
|
||||
to: opts.to,
|
||||
sid: result?.sid ?? null,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
messageId: res.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!result) return;
|
||||
if (waitSeconds === 0) return;
|
||||
await deps.waitForFinalStatus(
|
||||
result.client,
|
||||
result.sid,
|
||||
waitSeconds,
|
||||
pollSeconds,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { statusCommand } from "./status.js";
|
||||
|
||||
vi.mock("../twilio/messages.js", () => ({
|
||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadSessionStore: vi.fn().mockReturnValue({
|
||||
"+1000": { updatedAt: Date.now() - 60_000 },
|
||||
}),
|
||||
resolveStorePath: vi.fn().mockReturnValue("/tmp/sessions.json"),
|
||||
webAuthExists: vi.fn().mockResolvedValue(true),
|
||||
getWebAuthAgeMs: vi.fn().mockReturnValue(5000),
|
||||
logWebSelfId: vi.fn(),
|
||||
}));
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
loadSessionStore: mocks.loadSessionStore,
|
||||
resolveStorePath: mocks.resolveStorePath,
|
||||
}));
|
||||
vi.mock("../web/session.js", () => ({
|
||||
webAuthExists: mocks.webAuthExists,
|
||||
getWebAuthAgeMs: mocks.getWebAuthAgeMs,
|
||||
logWebSelfId: mocks.logWebSelfId,
|
||||
}));
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({ inbound: { reply: { session: {} } } }),
|
||||
}));
|
||||
|
||||
import { statusCommand } from "./status.js";
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const deps: CliDeps = {
|
||||
listRecentMessages: vi.fn(),
|
||||
} as unknown as CliDeps;
|
||||
|
||||
describe("statusCommand", () => {
|
||||
it("validates limit and lookback", async () => {
|
||||
await expect(
|
||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||
).rejects.toThrow("limit must be between 1 and 200");
|
||||
await expect(
|
||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||
).rejects.toThrow("lookback must be > 0 minutes");
|
||||
});
|
||||
|
||||
it("prints JSON when requested", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||
await statusCommand(
|
||||
{ limit: "5", lookback: "10", json: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify([{ sid: "1" }], null, 2),
|
||||
);
|
||||
await statusCommand({ json: true }, runtime as never);
|
||||
const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]);
|
||||
expect(payload.web.linked).toBe(true);
|
||||
expect(payload.sessions.count).toBe(1);
|
||||
expect(payload.sessions.path).toBe("/tmp/sessions.json");
|
||||
});
|
||||
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||
(runtime.log as vi.Mock).mockClear();
|
||||
await statusCommand({}, runtime as never);
|
||||
const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0]));
|
||||
expect(logs.some((l) => l.includes("Web session"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("Active sessions"))).toBe(true);
|
||||
expect(mocks.logWebSelfId).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,81 @@
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import { info } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { formatMessageLine } from "../twilio/messages.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import {
|
||||
getWebAuthAgeMs,
|
||||
logWebSelfId,
|
||||
webAuthExists,
|
||||
} from "../web/session.js";
|
||||
|
||||
const formatAge = (ms: number | null | undefined) => {
|
||||
if (!ms || ms < 0) return "unknown";
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
export async function statusCommand(
|
||||
opts: { limit: string; lookback: string; json?: boolean },
|
||||
deps: CliDeps,
|
||||
opts: { json?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||
throw new Error("limit must be between 1 and 200");
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||
throw new Error("lookback must be > 0 minutes");
|
||||
const cfg = loadConfig();
|
||||
const linked = await webAuthExists();
|
||||
const authAgeMs = getWebAuthAgeMs();
|
||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const sessions = Object.entries(store)
|
||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||
.map(([key, entry]) => ({ key, updatedAt: entry?.updatedAt ?? 0 }))
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const recent = sessions.slice(0, 5).map((s) => ({
|
||||
key: s.key,
|
||||
updatedAt: s.updatedAt || null,
|
||||
age: s.updatedAt ? Date.now() - s.updatedAt : null,
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
web: {
|
||||
linked,
|
||||
authAgeMs,
|
||||
},
|
||||
heartbeatSeconds,
|
||||
sessions: {
|
||||
path: storePath,
|
||||
count: sessions.length,
|
||||
recent,
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(summary, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
runtime.log(
|
||||
`Web session: ${linked ? "linked" : "not linked"}${linked ? ` (last refreshed ${formatAge(authAgeMs)})` : ""}`,
|
||||
);
|
||||
if (linked) {
|
||||
logWebSelfId(runtime, true);
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
runtime.log("No messages found in the requested window.");
|
||||
return;
|
||||
}
|
||||
for (const m of messages) {
|
||||
runtime.log(formatMessageLine(m));
|
||||
runtime.log(info(`Heartbeat: ${heartbeatSeconds}s`));
|
||||
runtime.log(info(`Session store: ${storePath}`));
|
||||
runtime.log(info(`Active sessions: ${sessions.length}`));
|
||||
if (recent.length > 0) {
|
||||
runtime.log("Recent sessions:");
|
||||
for (const r of recent) {
|
||||
runtime.log(
|
||||
`- ${r.key} (${r.updatedAt ? formatAge(Date.now() - r.updatedAt) : "no activity"})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
runtime.log("No session activity yet.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { upCommand } from "./up.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const makeDeps = (): CliDeps => ({
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
readEnv: vi.fn().mockReturnValue({
|
||||
whatsappFrom: "whatsapp:+1555",
|
||||
whatsappSenderSid: "WW",
|
||||
}),
|
||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
describe("upCommand", () => {
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it("performs dry run and returns mock data", async () => {
|
||||
runtime.log.mockClear();
|
||||
const result = await upCommand(
|
||||
{ port: "42873", path: "/cb", dryRun: true },
|
||||
makeDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would enable funnel on port 42873",
|
||||
);
|
||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||
expect(result?.senderSid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||
const deps = makeDeps();
|
||||
const res = await upCommand(
|
||||
{ port: "42873", path: "/hook", verbose: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||
"tailscale",
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||
expect(deps.startWebhook).toHaveBeenCalled();
|
||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"SID123",
|
||||
"https://tailnet-host/hook",
|
||||
"POST",
|
||||
runtime,
|
||||
);
|
||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||
// waiter is returned to keep the process alive in real use.
|
||||
expect(typeof res?.waiter).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { waitForever as defaultWaitForever } from "../cli/wait.js";
|
||||
import { retryAsync } from "../infra/retry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export async function upCommand(
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
await deps.ensurePortAvailable(port);
|
||||
const env = deps.readEnv(runtime);
|
||||
if (opts.dryRun) {
|
||||
runtime.log(`[dry-run] would enable funnel on port ${port}`);
|
||||
runtime.log(`[dry-run] would start webhook at path ${opts.path}`);
|
||||
runtime.log(`[dry-run] would update Twilio sender webhook`);
|
||||
const publicUrl = `https://dry-run${opts.path}`;
|
||||
return { server: undefined, publicUrl, senderSid: undefined, waiter };
|
||||
}
|
||||
await deps.ensureBinary("tailscale", undefined, runtime);
|
||||
await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||
const host = await deps.getTailnetHostname();
|
||||
const publicUrl = `https://${host}${opts.path}`;
|
||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
undefined,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
|
||||
if (!deps.createClient) {
|
||||
throw new Error("Twilio client dependency missing");
|
||||
}
|
||||
const twilioClient = deps.createClient(env);
|
||||
const senderSid = await deps.findWhatsappSenderSid(
|
||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||
env.whatsappFrom,
|
||||
env.whatsappSenderSid,
|
||||
runtime,
|
||||
);
|
||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||
|
||||
runtime.log(
|
||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||
);
|
||||
|
||||
return { server, publicUrl, senderSid, waiter };
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
import { webhookCommand } from "./webhook.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
const deps: CliDeps = {
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
};
|
||||
|
||||
describe("webhookCommand", () => {
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it("logs dry run instead of starting server", async () => {
|
||||
runtime.log.mockClear();
|
||||
const res = await webhookCommand(
|
||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would start webhook on port 42873 path /hook",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts webhook when valid", async () => {
|
||||
const res = await webhookCommand(
|
||||
{
|
||||
port: "42873",
|
||||
path: "/hook",
|
||||
reply: "ok",
|
||||
verbose: true,
|
||||
ingress: "none",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||
42873,
|
||||
"/hook",
|
||||
"ok",
|
||||
true,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toEqual({ server: true });
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { retryAsync } from "../infra/retry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { upCommand } from "./up.js";
|
||||
|
||||
export async function webhookCommand(
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
reply?: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
ingress?: "tailscale" | "none";
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
const ingress = opts.ingress ?? "tailscale";
|
||||
|
||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||
if (ingress === "tailscale") {
|
||||
const result = await upCommand(
|
||||
{
|
||||
port: opts.port,
|
||||
path: opts.path,
|
||||
verbose: opts.verbose,
|
||||
yes: opts.yes,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
return result.server;
|
||||
}
|
||||
|
||||
// Local-only webhook (no ingress / no Twilio update).
|
||||
await deps.ensurePortAvailable(port);
|
||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
opts.reply,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
return server;
|
||||
}
|
||||
@@ -94,25 +94,6 @@ export const CONFIG_PATH_CLAWDIS = path.join(
|
||||
".clawdis",
|
||||
"clawdis.json",
|
||||
);
|
||||
// Legacy path (fallback for backward compatibility)
|
||||
export const CONFIG_PATH_LEGACY = path.join(
|
||||
os.homedir(),
|
||||
".warelay",
|
||||
"warelay.json",
|
||||
);
|
||||
// Deprecated: kept for backward compatibility
|
||||
export const CONFIG_PATH = CONFIG_PATH_LEGACY;
|
||||
|
||||
/**
|
||||
* Resolve which config path to use.
|
||||
* Prefers new clawdis.json, falls back to warelay.json.
|
||||
*/
|
||||
function resolveConfigPath(): string {
|
||||
if (fs.existsSync(CONFIG_PATH_CLAWDIS)) {
|
||||
return CONFIG_PATH_CLAWDIS;
|
||||
}
|
||||
return CONFIG_PATH_LEGACY;
|
||||
}
|
||||
|
||||
const ReplySchema = z
|
||||
.object({
|
||||
@@ -231,8 +212,7 @@ const WarelaySchema = z.object({
|
||||
|
||||
export function loadConfig(): WarelayConfig {
|
||||
// Read config file (JSON5) if present.
|
||||
// Prefers ~/.clawdis/clawdis.json, falls back to ~/.warelay/warelay.json
|
||||
const configPath = resolveConfigPath();
|
||||
const configPath = CONFIG_PATH_CLAWDIS;
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) return {};
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
|
||||
@@ -17,14 +17,17 @@ export type SessionEntry = {
|
||||
verboseLevel?: string;
|
||||
};
|
||||
|
||||
export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json");
|
||||
export const SESSION_STORE_DEFAULT = path.join(
|
||||
CONFIG_DIR,
|
||||
"sessions",
|
||||
"sessions.json",
|
||||
);
|
||||
export const DEFAULT_RESET_TRIGGER = "/new";
|
||||
export const DEFAULT_IDLE_MINUTES = 60;
|
||||
|
||||
export function resolveStorePath(store?: string) {
|
||||
if (!store) return SESSION_STORE_DEFAULT;
|
||||
if (store.startsWith("~"))
|
||||
return path.resolve(store.replace("~", os.homedir()));
|
||||
if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir()));
|
||||
return path.resolve(store);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ensureTwilioEnv, readEnv } from "./env.js";
|
||||
import type { RuntimeEnv } from "./runtime.js";
|
||||
|
||||
const baseEnv = {
|
||||
TWILIO_ACCOUNT_SID: "AC123",
|
||||
TWILIO_WHATSAPP_FROM: "whatsapp:+1555",
|
||||
};
|
||||
|
||||
describe("env helpers", () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
throw new Error("exit");
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = {};
|
||||
});
|
||||
|
||||
function setEnv(vars: Record<string, string | undefined>) {
|
||||
process.env = {};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
it("reads env with auth token", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
expect(cfg.accountSid).toBe("AC123");
|
||||
expect(cfg.whatsappFrom).toBe("whatsapp:+1555");
|
||||
if ("authToken" in cfg.auth) {
|
||||
expect(cfg.auth.authToken).toBe("token");
|
||||
} else {
|
||||
throw new Error("Expected auth token");
|
||||
}
|
||||
});
|
||||
|
||||
it("reads env with API key/secret", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: "key",
|
||||
TWILIO_API_SECRET: "secret",
|
||||
});
|
||||
const cfg = readEnv(runtime);
|
||||
if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) {
|
||||
expect(cfg.auth.apiKey).toBe("key");
|
||||
expect(cfg.auth.apiSecret).toBe("secret");
|
||||
} else {
|
||||
throw new Error("Expected API key/secret");
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast on invalid env", () => {
|
||||
setEnv({
|
||||
TWILIO_ACCOUNT_SID: "",
|
||||
TWILIO_WHATSAPP_FROM: "",
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => readEnv(runtime)).toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ensureTwilioEnv passes when token present", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: "token",
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).not.toThrow();
|
||||
});
|
||||
|
||||
it("ensureTwilioEnv fails when missing auth", () => {
|
||||
setEnv({
|
||||
...baseEnv,
|
||||
TWILIO_AUTH_TOKEN: undefined,
|
||||
TWILIO_API_KEY: undefined,
|
||||
TWILIO_API_SECRET: undefined,
|
||||
});
|
||||
expect(() => ensureTwilioEnv(runtime)).toThrow("exit");
|
||||
});
|
||||
});
|
||||
106
src/env.ts
106
src/env.ts
@@ -1,106 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { danger } from "./globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||
|
||||
export type AuthMode =
|
||||
| { accountSid: string; authToken: string }
|
||||
| { accountSid: string; apiKey: string; apiSecret: string };
|
||||
|
||||
export type EnvConfig = {
|
||||
accountSid: string;
|
||||
whatsappFrom: string;
|
||||
whatsappSenderSid?: string;
|
||||
auth: AuthMode;
|
||||
};
|
||||
|
||||
const EnvSchema = z
|
||||
.object({
|
||||
TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"),
|
||||
TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"),
|
||||
TWILIO_SENDER_SID: z.string().optional(),
|
||||
TWILIO_AUTH_TOKEN: z.string().optional(),
|
||||
TWILIO_API_KEY: z.string().optional(),
|
||||
TWILIO_API_SECRET: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set",
|
||||
});
|
||||
}
|
||||
if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!val.TWILIO_AUTH_TOKEN &&
|
||||
!(val.TWILIO_API_KEY && val.TWILIO_API_SECRET)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig {
|
||||
// Load and validate Twilio auth + sender configuration from env.
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
runtime.error("Invalid environment configuration:");
|
||||
parsed.error.issues.forEach((iss) => {
|
||||
runtime.error(`- ${iss.message}`);
|
||||
});
|
||||
runtime.exit(1);
|
||||
}
|
||||
|
||||
const {
|
||||
TWILIO_ACCOUNT_SID: accountSid,
|
||||
TWILIO_WHATSAPP_FROM: whatsappFrom,
|
||||
TWILIO_SENDER_SID: whatsappSenderSid,
|
||||
TWILIO_AUTH_TOKEN: authToken,
|
||||
TWILIO_API_KEY: apiKey,
|
||||
TWILIO_API_SECRET: apiSecret,
|
||||
} = parsed.data;
|
||||
|
||||
let auth: AuthMode;
|
||||
if (apiKey && apiSecret) {
|
||||
auth = { accountSid, apiKey, apiSecret };
|
||||
} else if (authToken) {
|
||||
auth = { accountSid, authToken };
|
||||
} else {
|
||||
runtime.error("Missing Twilio auth configuration");
|
||||
runtime.exit(1);
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
return {
|
||||
accountSid,
|
||||
whatsappFrom,
|
||||
whatsappSenderSid,
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) {
|
||||
// Guardrails: fail fast when Twilio env vars are missing or incomplete.
|
||||
const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"];
|
||||
const missing = required.filter((k) => !process.env[k]);
|
||||
const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN);
|
||||
const hasKey = Boolean(
|
||||
process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET,
|
||||
);
|
||||
if (missing.length > 0 || (!hasToken && !hasKey)) {
|
||||
runtime.error(
|
||||
danger(
|
||||
`Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`,
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockTwilio } from "../test/mocks/twilio.js";
|
||||
import { statusCommand } from "./commands/status.js";
|
||||
import { createDefaultDeps } from "./index.js";
|
||||
import * as providerWeb from "./provider-web.js";
|
||||
import { defaultRuntime } from "./runtime.js";
|
||||
|
||||
vi.mock("twilio", () => {
|
||||
const { factory } = createMockTwilio();
|
||||
return { default: factory };
|
||||
});
|
||||
|
||||
import * as index from "./index.js";
|
||||
import * as provider from "./provider-web.js";
|
||||
|
||||
beforeEach(() => {
|
||||
index.program.exitOverride();
|
||||
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
||||
process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567";
|
||||
process.env.TWILIO_AUTH_TOKEN = "token";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CLI commands", () => {
|
||||
it("exposes login command", () => {
|
||||
const names = index.program.commands.map((c) => c.name());
|
||||
expect(names).toContain("login");
|
||||
});
|
||||
|
||||
it("send command routes to web provider", async () => {
|
||||
const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
[
|
||||
"send",
|
||||
"--to",
|
||||
"+1555",
|
||||
"--message",
|
||||
"hi",
|
||||
"--provider",
|
||||
"web",
|
||||
"--wait",
|
||||
"0",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(sendWeb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command uses twilio path when provider=twilio", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SM1" });
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command supports dry-run and skips sending", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command outputs JSON when requested", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" });
|
||||
const logSpy = vi.spyOn(defaultRuntime, "log");
|
||||
await index.program.parseAsync(
|
||||
["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"sid": "SMJSON"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("login command calls web login", async () => {
|
||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(["login"], { from: "user" });
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("status command prints JSON", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
twilio._client.messages.list
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "1",
|
||||
status: "delivered",
|
||||
direction: "inbound",
|
||||
dateCreated: new Date("2024-01-01T00:00:00Z"),
|
||||
from: "a",
|
||||
to: "b",
|
||||
body: "hi",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
sid: "2",
|
||||
status: "sent",
|
||||
direction: "outbound-api",
|
||||
dateCreated: new Date("2024-01-02T00:00:00Z"),
|
||||
from: "b",
|
||||
to: "a",
|
||||
body: "yo",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]);
|
||||
const runtime = {
|
||||
...defaultRuntime,
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
await statusCommand(
|
||||
{ limit: "1", lookback: "10", json: true },
|
||||
createDefaultDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@ describe("toWhatsappJid", () => {
|
||||
|
||||
describe("assertProvider", () => {
|
||||
it("accepts valid providers", () => {
|
||||
expect(() => assertProvider("twilio")).not.toThrow();
|
||||
expect(() => assertProvider("web")).not.toThrow();
|
||||
});
|
||||
|
||||
|
||||
65
src/index.ts
65
src/index.ts
@@ -3,12 +3,9 @@ import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import {
|
||||
autoReplyIfConfigured,
|
||||
getReplyFromConfig,
|
||||
} from "./auto-reply/reply.js";
|
||||
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
||||
import { applyTemplate } from "./auto-reply/templating.js";
|
||||
import { createDefaultDeps, monitorTwilio } from "./cli/deps.js";
|
||||
import { createDefaultDeps } from "./cli/deps.js";
|
||||
import { promptYesNo } from "./cli/prompt.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { loadConfig } from "./config/config.js";
|
||||
@@ -18,7 +15,6 @@ import {
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "./config/sessions.js";
|
||||
import { readEnv } from "./env.js";
|
||||
import { ensureBinary } from "./infra/binaries.js";
|
||||
import {
|
||||
describePortOwner,
|
||||
@@ -26,33 +22,9 @@ import {
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
} from "./infra/ports.js";
|
||||
import {
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensureTailscaledInstalled,
|
||||
getTailnetHostname,
|
||||
} from "./infra/tailscale.js";
|
||||
import { enableConsoleCapture } from "./logging.js";
|
||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||
import { monitorWebProvider } from "./provider-web.js";
|
||||
import { createClient } from "./twilio/client.js";
|
||||
import {
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
sortByDateDesc,
|
||||
uniqueBySid,
|
||||
} from "./twilio/messages.js";
|
||||
import { sendMessage, waitForFinalStatus } from "./twilio/send.js";
|
||||
import { findWhatsappSenderSid } from "./twilio/senders.js";
|
||||
import { sendTypingIndicator } from "./twilio/typing.js";
|
||||
import {
|
||||
findIncomingNumberSid as findIncomingNumberSidImpl,
|
||||
findMessagingServiceSid as findMessagingServiceSidImpl,
|
||||
setMessagingServiceWebhook as setMessagingServiceWebhookImpl,
|
||||
updateWebhook as updateWebhookImpl,
|
||||
} from "./twilio/update-webhook.js";
|
||||
import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js";
|
||||
import { startWebhook as startWebhookImpl } from "./twilio/webhook.js";
|
||||
import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js";
|
||||
|
||||
dotenv.config({ quiet: true });
|
||||
@@ -64,57 +36,28 @@ import { buildProgram } from "./cli/program.js";
|
||||
|
||||
const program = buildProgram();
|
||||
|
||||
// Keep aliases for backwards compatibility with prior index exports.
|
||||
const startWebhook = startWebhookImpl;
|
||||
const setMessagingServiceWebhook = setMessagingServiceWebhookImpl;
|
||||
const updateWebhook = updateWebhookImpl;
|
||||
|
||||
export {
|
||||
assertProvider,
|
||||
autoReplyIfConfigured,
|
||||
applyTemplate,
|
||||
createClient,
|
||||
createDefaultDeps,
|
||||
deriveSessionKey,
|
||||
describePortOwner,
|
||||
ensureBinary,
|
||||
ensureFunnel,
|
||||
ensureGoInstalled,
|
||||
ensurePortAvailable,
|
||||
ensureTailscaledInstalled,
|
||||
findIncomingNumberSidImpl as findIncomingNumberSid,
|
||||
findMessagingServiceSidImpl as findMessagingServiceSid,
|
||||
findWhatsappSenderSid,
|
||||
formatMessageLine,
|
||||
formatTwilioError,
|
||||
getReplyFromConfig,
|
||||
getTailnetHostname,
|
||||
handlePortError,
|
||||
logTwilioSendError,
|
||||
listRecentMessages,
|
||||
loadConfig,
|
||||
loadSessionStore,
|
||||
monitorTwilio,
|
||||
monitorWebProvider,
|
||||
normalizeE164,
|
||||
PortInUseError,
|
||||
promptYesNo,
|
||||
createDefaultDeps,
|
||||
readEnv,
|
||||
resolveStorePath,
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
saveSessionStore,
|
||||
sendMessage,
|
||||
sendTypingIndicator,
|
||||
setMessagingServiceWebhook,
|
||||
sortByDateDesc,
|
||||
startWebhook,
|
||||
updateWebhook,
|
||||
uniqueBySid,
|
||||
waitForFinalStatus,
|
||||
waitForever,
|
||||
toWhatsappJid,
|
||||
program,
|
||||
waitForever,
|
||||
};
|
||||
|
||||
const isMain =
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type Provider = "twilio" | "web";
|
||||
@@ -1,16 +0,0 @@
|
||||
export { createClient } from "../../twilio/client.js";
|
||||
export {
|
||||
formatMessageLine,
|
||||
listRecentMessages,
|
||||
} from "../../twilio/messages.js";
|
||||
export { monitorTwilio } from "../../twilio/monitor.js";
|
||||
export { sendMessage, waitForFinalStatus } from "../../twilio/send.js";
|
||||
export { findWhatsappSenderSid } from "../../twilio/senders.js";
|
||||
export { sendTypingIndicator } from "../../twilio/typing.js";
|
||||
export {
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
updateWebhook,
|
||||
} from "../../twilio/update-webhook.js";
|
||||
export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js";
|
||||
@@ -1,14 +0,0 @@
|
||||
import Twilio from "twilio";
|
||||
import type { EnvConfig } from "../env.js";
|
||||
|
||||
export function createClient(env: EnvConfig) {
|
||||
// Twilio client using either auth token or API key/secret.
|
||||
if ("authToken" in env.auth) {
|
||||
return Twilio(env.accountSid, env.auth.authToken, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
}
|
||||
return Twilio(env.auth.apiKey, env.auth.apiSecret, {
|
||||
accountSid: env.accountSid,
|
||||
});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HEARTBEAT_TOKEN } from "../web/auto-reply.js";
|
||||
import { runTwilioHeartbeatOnce } from "./heartbeat.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
// eslint-disable-next-line import/first
|
||||
import { sendMessage } from "./send.js";
|
||||
|
||||
const sendMessageMock = sendMessage as unknown as vi.Mock;
|
||||
const replyResolverMock = getReplyFromConfig as unknown as vi.Mock;
|
||||
|
||||
describe("runTwilioHeartbeatOnce", () => {
|
||||
it("sends manual override body and skips resolver", async () => {
|
||||
sendMessageMock.mockResolvedValue({});
|
||||
await runTwilioHeartbeatOnce({
|
||||
to: "+1555",
|
||||
overrideBody: "hello manual",
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"+1555",
|
||||
"hello manual",
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(replyResolverMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dry-run manual message avoids sending", async () => {
|
||||
sendMessageMock.mockReset();
|
||||
await runTwilioHeartbeatOnce({
|
||||
to: "+1555",
|
||||
overrideBody: "hello manual",
|
||||
dryRun: true,
|
||||
});
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(replyResolverMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips send when resolver returns heartbeat token", async () => {
|
||||
replyResolverMock.mockResolvedValue({
|
||||
text: HEARTBEAT_TOKEN,
|
||||
});
|
||||
sendMessageMock.mockReset();
|
||||
await runTwilioHeartbeatOnce({
|
||||
to: "+1555",
|
||||
});
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends resolved heartbeat text when present", async () => {
|
||||
replyResolverMock.mockResolvedValue({
|
||||
text: "ALERT!",
|
||||
});
|
||||
sendMessageMock.mockReset().mockResolvedValue({});
|
||||
await runTwilioHeartbeatOnce({
|
||||
to: "+1555",
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"+1555",
|
||||
"ALERT!",
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { danger, success } from "../globals.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../web/auto-reply.js";
|
||||
import { sendMessage } from "./send.js";
|
||||
|
||||
type ReplyResolver = typeof getReplyFromConfig;
|
||||
|
||||
export async function runTwilioHeartbeatOnce(opts: {
|
||||
to: string;
|
||||
verbose?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
replyResolver?: ReplyResolver;
|
||||
overrideBody?: string;
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
const {
|
||||
to,
|
||||
verbose: _verbose = false,
|
||||
runtime = defaultRuntime,
|
||||
overrideBody,
|
||||
dryRun = false,
|
||||
} = opts;
|
||||
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
||||
|
||||
if (overrideBody && overrideBody.trim().length === 0) {
|
||||
throw new Error("Override body must be non-empty when provided.");
|
||||
}
|
||||
|
||||
try {
|
||||
if (overrideBody) {
|
||||
if (dryRun) {
|
||||
logInfo(
|
||||
`[dry-run] twilio send -> ${to}: ${overrideBody.trim()} (manual message)`,
|
||||
runtime,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await sendMessage(to, overrideBody, undefined, runtime);
|
||||
logInfo(success(`sent manual message to ${to} (twilio)`), runtime);
|
||||
return;
|
||||
}
|
||||
|
||||
const replyResult = await replyResolver(
|
||||
{
|
||||
Body: HEARTBEAT_PROMPT,
|
||||
From: to,
|
||||
To: to,
|
||||
MessageSid: undefined,
|
||||
},
|
||||
{ isHeartbeat: true },
|
||||
);
|
||||
|
||||
const replyPayload = Array.isArray(replyResult)
|
||||
? replyResult[0]
|
||||
: replyResult;
|
||||
|
||||
if (
|
||||
!replyPayload ||
|
||||
(!replyPayload.text &&
|
||||
!replyPayload.mediaUrl &&
|
||||
!replyPayload.mediaUrls?.length)
|
||||
) {
|
||||
logInfo("heartbeat skipped: empty reply", runtime);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMedia = Boolean(
|
||||
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const stripped = stripHeartbeatToken(replyPayload.text);
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
logInfo(success("heartbeat: ok (HEARTBEAT_OK)"), runtime);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalText = stripped.text || replyPayload.text || "";
|
||||
if (dryRun) {
|
||||
logInfo(
|
||||
`[dry-run] heartbeat -> ${to}: ${finalText.slice(0, 200)}`,
|
||||
runtime,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessage(to, finalText, undefined, runtime);
|
||||
logInfo(success(`heartbeat sent to ${to} (twilio)`), runtime);
|
||||
} catch (err) {
|
||||
runtime.error(danger(`Heartbeat failed: ${String(err)}`));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { readEnv } from "../env.js";
|
||||
import { withWhatsAppPrefix } from "../utils.js";
|
||||
import { createClient } from "./client.js";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// Remove duplicates by SID while preserving order.
|
||||
export 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;
|
||||
}
|
||||
|
||||
// Sort messages newest -> oldest by dateCreated.
|
||||
export 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;
|
||||
});
|
||||
}
|
||||
|
||||
// Merge inbound/outbound messages (recent first) for status commands and tests.
|
||||
export async function listRecentMessages(
|
||||
lookbackMinutes: number,
|
||||
limit: number,
|
||||
clientOverride?: ReturnType<typeof createClient>,
|
||||
): Promise<ListedMessage[]> {
|
||||
const env = readEnv();
|
||||
const client = clientOverride ?? 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 inboundArr = Array.isArray(inbound) ? inbound : [];
|
||||
const outboundArr = Array.isArray(outbound) ? outbound : [];
|
||||
const combined = uniqueBySid(
|
||||
[...inboundArr, ...outboundArr].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);
|
||||
}
|
||||
|
||||
// Human-friendly single-line formatter for recent messages.
|
||||
export 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})`;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js";
|
||||
import { autoReplyIfConfigured } from "../auto-reply/reply.js";
|
||||
import { readEnv } from "../env.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { logDebug, logInfo, logWarn } from "../logger.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { sleep, withWhatsAppPrefix } from "../utils.js";
|
||||
import { createClient } from "./client.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;
|
||||
let backoffMs = 1_000;
|
||||
|
||||
const env = deps.readEnv(runtime);
|
||||
const from = withWhatsAppPrefix(env.whatsappFrom);
|
||||
const client = opts?.client ?? deps.createClient(env);
|
||||
logInfo(
|
||||
`📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`,
|
||||
runtime,
|
||||
);
|
||||
|
||||
let lastSeenSid: string | undefined;
|
||||
let iterations = 0;
|
||||
while (iterations < maxIterations) {
|
||||
let messages: ListedMessage[] = [];
|
||||
try {
|
||||
messages =
|
||||
(await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? [];
|
||||
backoffMs = 1_000; // reset after success
|
||||
} catch (err) {
|
||||
logWarn(
|
||||
`Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
await deps.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * 2, 10_000);
|
||||
continue;
|
||||
}
|
||||
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
|
||||
logDebug(`[${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 import("./types.js").TwilioRequester & {
|
||||
messages: { create: (opts: unknown) => Promise<unknown> };
|
||||
},
|
||||
m as unknown as MessageInstance,
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error(danger(`Auto-reply failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { readEnv } from "../env.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { sleep, withWhatsAppPrefix } from "../utils.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,
|
||||
opts?: { mediaUrl?: 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,
|
||||
mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined,
|
||||
});
|
||||
|
||||
logInfo(
|
||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
||||
runtime,
|
||||
);
|
||||
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)) {
|
||||
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||
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);
|
||||
}
|
||||
logInfo(
|
||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { danger, info, isVerbose, logVerbose } from "../globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { withWhatsAppPrefix } from "../utils.js";
|
||||
import type { TwilioSenderListClient } from "./types.js";
|
||||
|
||||
export async function findWhatsappSenderSid(
|
||||
client: TwilioSenderListClient,
|
||||
from: string,
|
||||
explicitSenderSid?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Use explicit sender SID if provided, otherwise list and match by sender_id.
|
||||
if (explicitSenderSid) {
|
||||
logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`);
|
||||
return explicitSenderSid;
|
||||
}
|
||||
try {
|
||||
// Prefer official SDK list helper to avoid request-shape mismatches.
|
||||
// Twilio helper types are broad; we narrow to expected shape.
|
||||
const senderClient = client as unknown as TwilioSenderListClient;
|
||||
const senders = await senderClient.messaging.v2.channelsSenders.list({
|
||||
channel: "whatsapp",
|
||||
pageSize: 50,
|
||||
});
|
||||
if (!senders) {
|
||||
throw new Error('List senders response missing "senders" array');
|
||||
}
|
||||
const match = senders.find(
|
||||
(s) =>
|
||||
(typeof s.senderId === "string" &&
|
||||
s.senderId === withWhatsAppPrefix(from)) ||
|
||||
(typeof s.sender_id === "string" &&
|
||||
s.sender_id === withWhatsAppPrefix(from)),
|
||||
);
|
||||
if (!match || typeof match.sid !== "string") {
|
||||
throw new Error(
|
||||
`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`,
|
||||
);
|
||||
}
|
||||
return match.sid;
|
||||
} catch (err) {
|
||||
runtime.error(danger("Unable to list WhatsApp senders via Twilio API."));
|
||||
if (isVerbose()) {
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
runtime.error(
|
||||
info(
|
||||
"Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).",
|
||||
),
|
||||
);
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>;
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { isVerbose, logVerbose, warn } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
type TwilioRequestOptions = {
|
||||
method: "get" | "post";
|
||||
uri: string;
|
||||
params?: Record<string, string | number>;
|
||||
form?: Record<string, string>;
|
||||
body?: unknown;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type TwilioRequester = {
|
||||
request: (options: TwilioRequestOptions) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function sendTypingIndicator(
|
||||
client: TwilioRequester,
|
||||
runtime: RuntimeEnv,
|
||||
messageSid?: string,
|
||||
) {
|
||||
// Best-effort WhatsApp typing indicator (public beta as of Nov 2025).
|
||||
if (!messageSid) {
|
||||
logVerbose("Skipping typing indicator: missing MessageSid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.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 (isVerbose()) {
|
||||
runtime.error(warn("Typing indicator failed (continuing without it)"));
|
||||
runtime.error(err as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } 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";
|
||||
process.env.TWILIO_AUTH_TOKEN = "dummy-token";
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
import { readEnv } from "../env.js";
|
||||
import { isVerbose } from "../globals.js";
|
||||
import { logError, logInfo } from "../logger.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import type { createClient } from "./client.js";
|
||||
import type { TwilioRequester, TwilioSenderListClient } 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",
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): 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;
|
||||
logInfo(
|
||||
`✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`,
|
||||
runtime,
|
||||
);
|
||||
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) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Sender updated but webhook callback_url missing; will try fallbacks",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders request update failed, will try client helpers: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime);
|
||||
return;
|
||||
}
|
||||
if (isVerbose())
|
||||
logError(
|
||||
"Form update succeeded but callback_url missing; will try helper fallback",
|
||||
runtime,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Form channelsSenders update failed, will try helper fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
logInfo(
|
||||
`✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`,
|
||||
runtime,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`channelsSenders helper update failed, will try phone number fallback: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isVerbose())
|
||||
logError(
|
||||
`Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
runtime.error(
|
||||
`❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`,
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { danger, info } from "../globals.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
|
||||
type TwilioApiError = {
|
||||
code?: number | string;
|
||||
status?: number | string;
|
||||
message?: string;
|
||||
moreInfo?: string;
|
||||
response?: { body?: unknown };
|
||||
};
|
||||
|
||||
export function formatTwilioError(err: unknown): string {
|
||||
// Normalize Twilio error objects into a single readable string.
|
||||
const e = err as TwilioApiError;
|
||||
const pieces = [];
|
||||
if (e.code != null) pieces.push(`code ${e.code}`);
|
||||
if (e.status != null) pieces.push(`status ${e.status}`);
|
||||
if (e.message) pieces.push(e.message);
|
||||
if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`);
|
||||
return pieces.length ? pieces.join(" | ") : String(err);
|
||||
}
|
||||
|
||||
export function logTwilioSendError(
|
||||
err: unknown,
|
||||
destination?: string,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
// Friendly error logger for send failures, including response body when present.
|
||||
const prefix = destination ? `to ${destination}: ` : "";
|
||||
runtime.error(
|
||||
danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`),
|
||||
);
|
||||
const body = (err as TwilioApiError)?.response?.body;
|
||||
if (body) {
|
||||
runtime.error(info("Response body:"), JSON.stringify(body, null, 2));
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { Server } from "node:http";
|
||||
import bodyParser from "body-parser";
|
||||
import chalk from "chalk";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
|
||||
import { type EnvConfig, readEnv } from "../env.js";
|
||||
import { danger, success } from "../globals.js";
|
||||
import * as mediaHost from "../media/host.js";
|
||||
import { attachMediaRoutes } from "../media/server.js";
|
||||
import { saveMediaSource } from "../media/store.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { normalizePath } from "../utils.js";
|
||||
import { createClient } from "./client.js";
|
||||
import { sendTypingIndicator } from "./typing.js";
|
||||
import { logTwilioSendError } from "./utils.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();
|
||||
|
||||
attachMediaRoutes(app, undefined, runtime);
|
||||
// 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 numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
|
||||
let mediaPath: string | undefined;
|
||||
let mediaUrlInbound: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
|
||||
mediaUrlInbound = req.body.MediaUrl0 as string;
|
||||
mediaType =
|
||||
typeof req.body?.MediaContentType0 === "string"
|
||||
? (req.body.MediaContentType0 as string)
|
||||
: undefined;
|
||||
try {
|
||||
const creds = buildTwilioBasicAuth(env);
|
||||
const saved = await saveMediaSource(
|
||||
mediaUrlInbound,
|
||||
{
|
||||
Authorization: `Basic ${creds}`,
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
if (!mediaType && saved.contentType) mediaType = saved.contentType;
|
||||
} catch (err) {
|
||||
runtime.error(
|
||||
danger(`Failed to download inbound media: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = createClient(env);
|
||||
let replyResult: ReplyPayload | ReplyPayload[] | undefined =
|
||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||
if (!replyResult) {
|
||||
replyResult = await getReplyFromConfig(
|
||||
{
|
||||
Body,
|
||||
From,
|
||||
To,
|
||||
MessageSid,
|
||||
MediaPath: mediaPath,
|
||||
MediaUrl: mediaUrlInbound,
|
||||
MediaType: mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const replyPayload = Array.isArray(replyResult)
|
||||
? replyResult[0]
|
||||
: replyResult;
|
||||
|
||||
if (replyPayload && (replyPayload.text || replyPayload.mediaUrl)) {
|
||||
try {
|
||||
let mediaUrl = replyPayload.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await mediaHost.ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: To,
|
||||
to: From,
|
||||
body: replyPayload.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
});
|
||||
if (verbose)
|
||||
runtime.log(
|
||||
success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`),
|
||||
);
|
||||
} 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("clawdis 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);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTwilioBasicAuth(env: EnvConfig) {
|
||||
if ("authToken" in env.auth) {
|
||||
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString(
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
15
src/utils.ts
15
src/utils.ts
@@ -7,11 +7,11 @@ export async function ensureDir(dir: string) {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export type Provider = "twilio" | "web";
|
||||
export type Provider = "web";
|
||||
|
||||
export function assertProvider(input: string): asserts input is Provider {
|
||||
if (input !== "twilio" && input !== "web") {
|
||||
throw new Error("Provider must be 'twilio' or 'web'");
|
||||
if (input !== "web") {
|
||||
throw new Error("Provider must be 'web'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +71,5 @@ export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Prefer new branding directory; fall back to legacy for compatibility.
|
||||
export const CONFIG_DIR = (() => {
|
||||
const clawdis = path.join(os.homedir(), ".clawdis");
|
||||
const legacy = path.join(os.homedir(), ".warelay");
|
||||
if (fs.existsSync(clawdis)) return clawdis;
|
||||
return legacy;
|
||||
})();
|
||||
// Fixed configuration root; legacy ~/.warelay is no longer used.
|
||||
export const CONFIG_DIR = path.join(os.homedir(), ".clawdis");
|
||||
|
||||
@@ -301,8 +301,7 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
const stripped = stripHeartbeatToken(replyPayload.text);
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
|
||||
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
|
||||
@@ -350,8 +349,7 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
}
|
||||
|
||||
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const candidates = Object.entries(store).filter(([key]) => key !== "global");
|
||||
if (candidates.length === 0) {
|
||||
@@ -372,7 +370,7 @@ function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||
const sessionCfg = cfg.inbound?.reply?.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
if (scope === "global") return [];
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
return Object.entries(store)
|
||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("web logout", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "warelay-logout-"));
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-logout-"));
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tmpDir);
|
||||
});
|
||||
|
||||
@@ -32,10 +32,16 @@ describe("web logout", () => {
|
||||
});
|
||||
|
||||
it("deletes cached credentials when present", async () => {
|
||||
const credsDir = path.join(tmpDir, ".warelay", "credentials");
|
||||
const credsDir = path.join(tmpDir, ".clawdis", "credentials");
|
||||
fs.mkdirSync(credsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(credsDir, "creds.json"), "{}");
|
||||
const sessionsPath = path.join(tmpDir, ".warelay", "sessions.json");
|
||||
const sessionsPath = path.join(
|
||||
tmpDir,
|
||||
".clawdis",
|
||||
"sessions",
|
||||
"sessions.json",
|
||||
);
|
||||
fs.mkdirSync(path.dirname(sessionsPath), { recursive: true });
|
||||
fs.writeFileSync(sessionsPath, "{}");
|
||||
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
|
||||
|
||||
|
||||
@@ -212,9 +212,12 @@ export function logWebSelfId(
|
||||
}
|
||||
|
||||
export async function pickProvider(pref: Provider | "auto"): Promise<Provider> {
|
||||
// Auto-select web when logged in; otherwise fall back to twilio.
|
||||
if (pref !== "auto") return pref;
|
||||
const choice: Provider = pref === "auto" ? "web" : pref;
|
||||
const hasWeb = await webAuthExists();
|
||||
if (hasWeb) return "web";
|
||||
return "twilio";
|
||||
if (!hasWeb) {
|
||||
throw new Error(
|
||||
"No WhatsApp Web session found. Run `clawdis login --verbose` to link.",
|
||||
);
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import * as impl from "../twilio/webhook.js";
|
||||
import * as entry from "./server.js";
|
||||
|
||||
describe("webhook server wrapper", () => {
|
||||
it("re-exports startWebhook", () => {
|
||||
expect(entry.startWebhook).toBe(impl.startWebhook);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
import { startWebhook } from "../twilio/webhook.js";
|
||||
|
||||
// Thin wrapper to keep webhook server co-located with other webhook helpers.
|
||||
export { startWebhook };
|
||||
|
||||
export type WebhookServer = Awaited<ReturnType<typeof startWebhook>>;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import * as impl from "../twilio/update-webhook.js";
|
||||
import * as entry from "./update.js";
|
||||
|
||||
describe("webhook update wrappers", () => {
|
||||
it("mirror the Twilio implementations", () => {
|
||||
expect(entry.updateWebhook).toBe(impl.updateWebhook);
|
||||
expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid);
|
||||
expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid);
|
||||
expect(entry.setMessagingServiceWebhook).toBe(
|
||||
impl.setMessagingServiceWebhook,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
/* istanbul ignore file */
|
||||
export {
|
||||
findIncomingNumberSid,
|
||||
findMessagingServiceSid,
|
||||
setMessagingServiceWebhook,
|
||||
updateWebhook,
|
||||
} from "../twilio/update-webhook.js";
|
||||
@@ -1,54 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
type MockFn<T extends (...args: never[]) => unknown> = ReturnType<typeof vi.fn<T>>;
|
||||
|
||||
export type MockTwilioClient = {
|
||||
messages: ((sid?: string) => { fetch: MockFn<() => unknown> }) & {
|
||||
create: MockFn<() => unknown>;
|
||||
list: MockFn<() => unknown>;
|
||||
};
|
||||
request?: MockFn<() => unknown>;
|
||||
messaging?: {
|
||||
v2: { channelsSenders: ((sid?: string) => { fetch: MockFn<() => unknown>; update: MockFn<() => unknown> }) & { list: MockFn<() => unknown> } };
|
||||
v1: { services: MockFn<() => { update: MockFn<() => unknown>; fetch: MockFn<() => unknown> }> };
|
||||
};
|
||||
incomingPhoneNumbers?: ((sid?: string) => { update: MockFn<() => unknown> }) & {
|
||||
list: MockFn<() => unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export function createMockTwilio() {
|
||||
const messages = Object.assign(vi.fn((sid?: string) => ({ fetch: vi.fn() })), {
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
});
|
||||
|
||||
const channelsSenders = Object.assign(
|
||||
vi.fn((sid?: string) => ({ fetch: vi.fn(), update: vi.fn() })),
|
||||
{ list: vi.fn() },
|
||||
);
|
||||
|
||||
const services = vi.fn(() => ({ update: vi.fn(), fetch: vi.fn() }));
|
||||
|
||||
const incomingPhoneNumbers = Object.assign(
|
||||
vi.fn((sid?: string) => ({ update: vi.fn() })),
|
||||
{ list: vi.fn() },
|
||||
);
|
||||
|
||||
const client: MockTwilioClient = {
|
||||
messages,
|
||||
request: vi.fn(),
|
||||
messaging: {
|
||||
v2: { channelsSenders },
|
||||
v1: { services },
|
||||
},
|
||||
incomingPhoneNumbers,
|
||||
};
|
||||
|
||||
const factory = Object.assign(vi.fn(() => client), {
|
||||
_client: client,
|
||||
_createClient: () => client,
|
||||
});
|
||||
|
||||
return { client, factory };
|
||||
}
|
||||
Reference in New Issue
Block a user