Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81f432af5 | ||
|
|
dda017df23 | ||
|
|
46be5eac7d | ||
|
|
e4076d14c0 | ||
|
|
20fc412765 | ||
|
|
c251681a40 |
No files matched your search
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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.";
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
Reference in new issue
Block a user