chore: drop twilio and go web-only

This commit is contained in:
Peter Steinberger
2025-12-05 19:03:59 +00:00
parent 869cc3d497
commit 7c7314f673
50 changed files with 335 additions and 5019 deletions

View File

@@ -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 {

View File

@@ -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,
}),
);
});
});

View File

@@ -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));
}
}
}

View File

@@ -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 };

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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,
});
}
}
}

View File

@@ -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"'),
);
});
});

View File

@@ -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,
);
}

View File

@@ -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();
});
});

View File

@@ -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.");
}
}

View File

@@ -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");
});
});

View File

@@ -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 };
}

View File

@@ -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 });
});
});

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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);
}

View File

@@ -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");
});
});

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -19,7 +19,6 @@ describe("toWhatsappJid", () => {
describe("assertProvider", () => {
it("accepts valid providers", () => {
expect(() => assertProvider("twilio")).not.toThrow();
expect(() => assertProvider("web")).not.toThrow();
});

View File

@@ -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 =

View File

@@ -1 +0,0 @@
export type Provider = "twilio" | "web";

View File

@@ -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";

View File

@@ -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,
});
}

View File

@@ -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(),
);
});
});

View File

@@ -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;
}
}

View File

@@ -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})`;
}

View File

@@ -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);
});
});

View File

@@ -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)}`));
}
}
}

View File

@@ -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);
});
});

View File

@@ -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,
);
}

View File

@@ -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);
}
}

View File

@@ -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>;
};

View File

@@ -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);
}
}
}

View File

@@ -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);
});
});

View File

@@ -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`,
);
}

View File

@@ -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));
}
}

View File

@@ -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",
);
}

View File

@@ -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");

View File

@@ -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")

View File

@@ -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");

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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>>;

View File

@@ -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,
);
});
});

View File

@@ -1,7 +0,0 @@
/* istanbul ignore file */
export {
findIncomingNumberSid,
findMessagingServiceSid,
setMessagingServiceWebhook,
updateWebhook,
} from "../twilio/update-webhook.js";

View File

@@ -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 };
}