Compare commits

..
6 Commits
9 changed files with 345 additions and 114 deletions

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## [Unreleased] 1.0.5
### Pending
- (add entries here)
## 1.0.4 — 2025-11-25
### Changes
- Auto-replies now send a WhatsApp fallback message when a command/Claude run hits the timeout, including up to 800 chars of partial stdout so the user still sees progress.
- Added tests covering the new timeout fallback behavior and partial-output truncation.
- Web relay auto-reconnects after Baileys/WebSocket drops (with log-out detection) and exposes close events for monitoring; added tests for close propagation and reconnect loop.
## 0.1.3 — 2025-11-25 ## 0.1.3 — 2025-11-25
### Features ### Features
+1 -1
View File
@@ -30,6 +30,6 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
6) **Post-publish** 6) **Post-publish**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`). - [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- [ ] Create/refresh the GitHub release for `vX.Y.Z`; body should be the product-facing bullets from the changelog; attach the `npm pack` tarball + checksums if you generated them. - [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `warelay X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**; attach the `npm pack` tarball + checksums if you generated them.
- [ ] From a clean temp directory (no `package.json`), run `npx -y warelay@X.Y.Z send --help` to confirm install/CLI entrypoints work. - [ ] From a clean temp directory (no `package.json`), run `npx -y warelay@X.Y.Z send --help` to confirm install/CLI entrypoints work.
- [ ] Announce/share release notes. - [ ] Announce/share release notes.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "warelay", "name": "warelay",
"version": "0.1.3", "version": "1.0.4",
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio", "description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+8
View File
@@ -386,6 +386,14 @@ export async function getReplyFromConfig(
console.error( console.error(
`Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`, `Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`,
); );
const baseMsg = `Command timed out after ${timeoutSeconds}s. Try a shorter prompt or split the request.`;
const partial = errorObj.stdout?.trim();
const partialSnippet =
partial && partial.length > 800 ? `${partial.slice(0, 800)}...` : partial;
const text = partialSnippet
? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}`
: baseMsg;
return { text };
} else { } else {
logError( logError(
`Command auto-reply failed after ${elapsed}ms: ${String(err)}`, `Command auto-reply failed after ${elapsed}ms: ${String(err)}`,
+2 -1
View File
@@ -8,6 +8,7 @@ import { danger, info, setVerbose, setYes, warn } from "../globals.js";
import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js"; import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import type { Provider } from "../utils.js"; import type { Provider } from "../utils.js";
import { VERSION } from "../version.js";
import { import {
createDefaultDeps, createDefaultDeps,
logTwilioFrom, logTwilioFrom,
@@ -18,7 +19,7 @@ import { spawnRelayTmux } from "./relay_tmux.js";
export function buildProgram() { export function buildProgram() {
const program = new Command(); const program = new Command();
const PROGRAM_VERSION = "0.1.3"; const PROGRAM_VERSION = VERSION;
const TAGLINE = const TAGLINE =
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked."; "Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
+60
View File
@@ -300,6 +300,66 @@ describe("config and templating", () => {
expect(result?.mediaUrl).toBeUndefined(); expect(result?.mediaUrl).toBeUndefined();
}); });
it("returns timeout reply with partial stdout snippet", async () => {
const partial = "x".repeat(900);
const runSpy = vi.fn().mockRejectedValue({
killed: true,
signal: "SIGKILL",
stdout: partial,
stderr: "",
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
timeoutSeconds: 42,
},
},
};
const result = await index.getReplyFromConfig(
{ Body: "hi", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(result?.text).toContain("Command timed out after 42s");
expect(result?.text).toContain("Partial output before timeout");
expect(result?.text).toContain(`${partial.slice(0, 800)}...`);
expect(result?.text).not.toContain(partial);
});
it("returns timeout reply without partial output when none is available", async () => {
const runSpy = vi.fn().mockRejectedValue({
killed: true,
signal: "SIGKILL",
stdout: "",
stderr: "",
});
const cfg = {
inbound: {
reply: {
mode: "command" as const,
command: ["echo", "{{Body}}"],
timeoutSeconds: 5,
},
},
};
const result = await index.getReplyFromConfig(
{ Body: "hi", From: "+1", To: "+2" },
undefined,
cfg,
runSpy,
);
expect(result?.text).toBe(
"Command timed out after 5s. Try a shorter prompt or split the request.",
);
});
it("splitMediaFromOutput strips media token and preserves text", () => { it("splitMediaFromOutput strips media token and preserves text", () => {
const { text, mediaUrl } = splitMediaFromOutput( const { text, mediaUrl } = splitMediaFromOutput(
"line1\nMEDIA:https://x/y.png\nline2", "line1\nMEDIA:https://x/y.png\nline2",
+60
View File
@@ -236,6 +236,23 @@ describe("provider-web", () => {
await listener.close(); await listener.close();
}); });
it("monitorWebInbox resolves onClose when the socket closes", async () => {
const listener = await monitorWebInbox({
verbose: false,
onMessage: vi.fn(),
});
const sock = getLastSocket();
const reasonPromise = listener.onClose;
sock.ev.emit("connection.update", {
connection: "close",
lastDisconnect: { error: { output: { statusCode: 500 } } },
});
await expect(reasonPromise).resolves.toEqual(
expect.objectContaining({ status: 500, isLoggedOut: false }),
);
await listener.close();
});
it("monitorWebInbox logs inbound bodies to file", async () => { it("monitorWebInbox logs inbound bodies to file", async () => {
const logPath = path.join( const logPath = path.join(
os.tmpdir(), os.tmpdir(),
@@ -300,6 +317,49 @@ describe("provider-web", () => {
await listener.close(); await listener.close();
}); });
it("monitorWebProvider reconnects after a connection close", async () => {
vi.useFakeTimers();
const closeResolvers: Array<() => void> = [];
const listenerFactory = vi.fn(async () => {
let resolve!: () => void;
const onClose = new Promise<void>((res) => {
resolve = res;
closeResolvers.push(res);
});
return { close: vi.fn(), onClose };
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const controller = new AbortController();
const run = monitorWebProvider(
false,
listenerFactory,
true,
async () => ({ text: "ok" }),
runtime as never,
controller.signal,
);
await Promise.resolve();
expect(listenerFactory).toHaveBeenCalledTimes(1);
closeResolvers[0]?.();
await Promise.resolve();
await vi.runOnlyPendingTimersAsync();
expect(listenerFactory).toHaveBeenCalledTimes(2);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Reconnecting"),
);
controller.abort();
closeResolvers[1]?.();
await vi.runAllTimersAsync();
await run;
});
it("monitorWebProvider falls back to text when media send fails", async () => { it("monitorWebProvider falls back to text when media send fails", async () => {
const sendMedia = vi.fn().mockRejectedValue(new Error("boom")); const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(undefined);
+194 -111
View File
@@ -23,6 +23,7 @@ import { saveMediaBuffer } from "./media/store.js";
import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
import type { Provider } from "./utils.js"; import type { Provider } from "./utils.js";
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js"; import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
import { VERSION } from "./version.js";
function formatDuration(ms: number) { function formatDuration(ms: number) {
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
@@ -53,7 +54,7 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
version, version,
logger, logger,
printQRInTerminal: false, printQRInTerminal: false,
browser: ["warelay", "cli", "0.1.3"], browser: ["warelay", "cli", VERSION],
syncFullHistory: false, syncFullHistory: false,
markOnlineOnConnect: false, markOnlineOnConnect: false,
}); });
@@ -228,6 +229,12 @@ export function webAuthExists() {
.catch(() => false); .catch(() => false);
} }
type WebListenerCloseReason = {
status?: number;
isLoggedOut: boolean;
error?: unknown;
};
export type WebInboundMessage = { export type WebInboundMessage = {
id?: string; id?: string;
from: string; from: string;
@@ -254,6 +261,10 @@ export async function monitorWebInbox(options: {
const inboundLogger = getChildLogger({ module: "web-inbound" }); const inboundLogger = getChildLogger({ module: "web-inbound" });
const sock = await createWaSocket(false, options.verbose); const sock = await createWaSocket(false, options.verbose);
await waitForWaConnection(sock); await waitForWaConnection(sock);
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
onCloseResolve = resolve;
});
try { try {
// Advertise that the relay is online right after connecting. // Advertise that the relay is online right after connecting.
await sock.sendPresenceUpdate("available"); await sock.sendPresenceUpdate("available");
@@ -372,6 +383,20 @@ export async function monitorWebInbox(options: {
} }
}); });
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
if (update.connection === "close") {
const status = getStatusCode(update.lastDisconnect?.error);
onCloseResolve?.({
status,
isLoggedOut: status === DisconnectReason.loggedOut,
error: update.lastDisconnect?.error,
});
}
},
);
return { return {
close: async () => { close: async () => {
try { try {
@@ -380,6 +405,7 @@ export async function monitorWebInbox(options: {
logVerbose(`Socket close failed: ${String(err)}`); logVerbose(`Socket close failed: ${String(err)}`);
} }
}, },
onClose,
}; };
} }
@@ -389,139 +415,196 @@ export async function monitorWebProvider(
keepAlive = true, keepAlive = true,
replyResolver: typeof getReplyFromConfig = getReplyFromConfig, replyResolver: typeof getReplyFromConfig = getReplyFromConfig,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
abortSignal?: AbortSignal,
) { ) {
const replyLogger = getChildLogger({ module: "web-auto-reply" }); const replyLogger = getChildLogger({ module: "web-auto-reply" });
// Listen for inbound personal WhatsApp Web messages and auto-reply if configured. const stopRequested = () => abortSignal?.aborted === true;
const listener = await listenerFactory({ const abortPromise =
verbose, abortSignal &&
onMessage: async (msg) => { new Promise<"aborted">((resolve) =>
const ts = msg.timestamp abortSignal.addEventListener("abort", () => resolve("aborted"), {
? new Date(msg.timestamp).toISOString() once: true,
: new Date().toISOString(); }),
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); );
const replyStarted = Date.now(); const sleep = (ms: number) =>
const replyResult = await replyResolver( new Promise<void>((resolve) => setTimeout(resolve, ms));
{
Body: msg.body, while (true) {
From: msg.from, if (stopRequested()) break;
To: msg.to,
MessageSid: msg.id, const listener = await listenerFactory({
MediaPath: msg.mediaPath, verbose,
MediaUrl: msg.mediaUrl, onMessage: async (msg) => {
MediaType: msg.mediaType, const ts = msg.timestamp
}, ? new Date(msg.timestamp).toISOString()
{ : new Date().toISOString();
onReplyStart: msg.sendComposing, console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
},
); const replyStarted = Date.now();
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) { const replyResult = await replyResolver(
logVerbose("Skipping auto-reply: no text/media returned from resolver"); {
return; Body: msg.body,
} From: msg.from,
try { To: msg.to,
if (replyResult.mediaUrl) { MessageSid: msg.id,
logVerbose(`Web auto-reply media detected: ${replyResult.mediaUrl}`); MediaPath: msg.mediaPath,
try { MediaUrl: msg.mediaUrl,
const media = await loadWebMedia(replyResult.mediaUrl); MediaType: msg.mediaType,
if (isVerbose()) { },
logVerbose( {
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`, onReplyStart: msg.sendComposing,
); },
} );
await msg.sendMedia({ if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) {
image: media.buffer, logVerbose("Skipping auto-reply: no text/media returned from resolver");
caption: replyResult.text || undefined, return;
mimetype: media.contentType, }
}); try {
logInfo( if (replyResult.mediaUrl) {
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`, logVerbose(
runtime, `Web auto-reply media detected: ${replyResult.mediaUrl}`,
); );
replyLogger.info( try {
{ const media = await loadWebMedia(replyResult.mediaUrl);
to: msg.from, if (isVerbose()) {
from: msg.to, logVerbose(
text: replyResult.text ?? null, `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
mediaUrl: replyResult.mediaUrl, );
mediaSizeBytes: media.buffer.length, }
durationMs: Date.now() - replyStarted, await msg.sendMedia({
}, image: media.buffer,
"auto-reply sent (media)", caption: replyResult.text || undefined,
); mimetype: media.contentType,
} catch (err) { });
console.error(
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
);
if (replyResult.text) {
await msg.reply(replyResult.text);
logInfo( logInfo(
`⚠️ Media skipped; sent text-only to ${msg.from}`, `✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
runtime, runtime,
); );
replyLogger.info( replyLogger.info(
{ {
to: msg.from, to: msg.from,
from: msg.to, from: msg.to,
text: replyResult.text, text: replyResult.text ?? null,
mediaUrl: replyResult.mediaUrl, mediaUrl: replyResult.mediaUrl,
mediaSizeBytes: media.buffer.length,
durationMs: Date.now() - replyStarted, durationMs: Date.now() - replyStarted,
mediaSendFailed: true,
}, },
"auto-reply sent (text fallback)", "auto-reply sent (media)",
); );
} catch (err) {
console.error(
danger(
`Failed sending web media to ${msg.from}: ${String(err)}`,
),
);
if (replyResult.text) {
await msg.reply(replyResult.text);
logInfo(
`⚠️ Media skipped; sent text-only to ${msg.from}`,
runtime,
);
replyLogger.info(
{
to: msg.from,
from: msg.to,
text: replyResult.text,
mediaUrl: replyResult.mediaUrl,
durationMs: Date.now() - replyStarted,
mediaSendFailed: true,
},
"auto-reply sent (text fallback)",
);
}
} }
} else {
await msg.reply(replyResult.text ?? "");
} }
} else { const durationMs = Date.now() - replyStarted;
await msg.reply(replyResult.text ?? ""); if (isVerbose()) {
} console.log(
const durationMs = Date.now() - replyStarted; success(
if (isVerbose()) { `↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${replyResult.mediaUrl ? ", media" : ""}, ${formatDuration(durationMs)})`,
console.log( ),
success( );
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${replyResult.mediaUrl ? ", media" : ""}, ${formatDuration(durationMs)})`, } else {
), console.log(
success(
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl ? " (media)" : ""}`,
),
);
}
replyLogger.info(
{
to: msg.from,
from: msg.to,
text: replyResult.text ?? null,
mediaUrl: replyResult.mediaUrl,
durationMs,
},
"auto-reply sent",
); );
} else { } catch (err) {
console.log( console.error(
success( danger(
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl ? " (media)" : ""}`, `Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
), ),
); );
} }
replyLogger.info( },
{
to: msg.from,
from: msg.to,
text: replyResult.text ?? null,
mediaUrl: replyResult.mediaUrl,
durationMs,
},
"auto-reply sent",
);
} catch (err) {
console.error(
danger(
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
),
);
}
},
});
logInfo(
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
runtime,
);
process.on("SIGINT", () => {
void listener.close().finally(() => {
logInfo("👋 Web monitor stopped", runtime);
runtime.exit(0);
}); });
});
if (keepAlive) { logInfo(
await waitForever(); "📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
runtime,
);
let stop = false;
process.on("SIGINT", () => {
stop = true;
void listener.close().finally(() => {
logInfo("👋 Web monitor stopped", runtime);
runtime.exit(0);
});
});
if (!keepAlive) return;
const reason = await Promise.race([
listener.onClose ?? waitForever(),
abortPromise ?? waitForever(),
]);
if (stopRequested() || stop || reason === "aborted") {
await listener.close();
break;
}
const status =
(typeof reason === "object" && reason && "status" in reason
? (reason as WebListenerCloseReason).status
: undefined) ?? "unknown";
const loggedOut =
typeof reason === "object" &&
reason &&
"isLoggedOut" in reason &&
(reason as WebListenerCloseReason).isLoggedOut;
if (loggedOut) {
runtime.error(
danger(
"WhatsApp session logged out. Run `warelay login --provider web` to relink.",
),
);
break;
}
runtime.error(
danger(
`WhatsApp Web connection closed (status ${status}). Reconnecting in 2s…`,
),
);
await listener.close();
await sleep(2_000);
} }
} }
+7
View File
@@ -0,0 +1,7 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version?: string };
// Single source of truth for the current warelay version (reads from package.json).
export const VERSION = pkg.version ?? "0.0.0";