Web relay: auto-reconnect Baileys and test
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
### Pending
|
### Pending
|
||||||
- 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.
|
- 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.
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -229,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;
|
||||||
@@ -255,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");
|
||||||
@@ -373,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 {
|
||||||
@@ -381,6 +405,7 @@ export async function monitorWebInbox(options: {
|
|||||||
logVerbose(`Socket close failed: ${String(err)}`);
|
logVerbose(`Socket close failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onClose,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user