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,9 +415,24 @@ 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 abortPromise =
|
||||||
|
abortSignal &&
|
||||||
|
new Promise<"aborted">((resolve) =>
|
||||||
|
abortSignal.addEventListener("abort", () => resolve("aborted"), {
|
||||||
|
once: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sleep = (ms: number) =>
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (stopRequested()) break;
|
||||||
|
|
||||||
const listener = await listenerFactory({
|
const listener = await listenerFactory({
|
||||||
verbose,
|
verbose,
|
||||||
onMessage: async (msg) => {
|
onMessage: async (msg) => {
|
||||||
@@ -422,7 +462,9 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (replyResult.mediaUrl) {
|
if (replyResult.mediaUrl) {
|
||||||
logVerbose(`Web auto-reply media detected: ${replyResult.mediaUrl}`);
|
logVerbose(
|
||||||
|
`Web auto-reply media detected: ${replyResult.mediaUrl}`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const media = await loadWebMedia(replyResult.mediaUrl);
|
const media = await loadWebMedia(replyResult.mediaUrl);
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
@@ -452,7 +494,9 @@ export async function monitorWebProvider(
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
|
danger(
|
||||||
|
`Failed sending web media to ${msg.from}: ${String(err)}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (replyResult.text) {
|
if (replyResult.text) {
|
||||||
await msg.reply(replyResult.text);
|
await msg.reply(replyResult.text);
|
||||||
@@ -514,15 +558,53 @@ export async function monitorWebProvider(
|
|||||||
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
let stop = false;
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
|
stop = true;
|
||||||
void listener.close().finally(() => {
|
void listener.close().finally(() => {
|
||||||
logInfo("👋 Web monitor stopped", runtime);
|
logInfo("👋 Web monitor stopped", runtime);
|
||||||
runtime.exit(0);
|
runtime.exit(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (keepAlive) {
|
if (!keepAlive) return;
|
||||||
await waitForever();
|
|
||||||
|
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