feat: unify gateway heartbeat
This commit is contained in:
@@ -15,19 +15,11 @@ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import * as commandQueue from "../process/command-queue.js";
|
||||
import {
|
||||
HEARTBEAT_PROMPT,
|
||||
HEARTBEAT_TOKEN,
|
||||
monitorWebProvider,
|
||||
resolveHeartbeatRecipients,
|
||||
resolveReplyHeartbeatIntervalMs,
|
||||
runWebHeartbeatOnce,
|
||||
SILENT_REPLY_TOKEN,
|
||||
stripHeartbeatToken,
|
||||
} from "./auto-reply.js";
|
||||
import type { sendMessageWhatsApp } from "./outbound.js";
|
||||
import { requestReplyHeartbeatNow } from "./reply-heartbeat-wake.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
@@ -107,146 +99,6 @@ const makeSessionStore = async (
|
||||
};
|
||||
};
|
||||
|
||||
describe("heartbeat helpers", () => {
|
||||
it("strips heartbeat token and skips when only token", () => {
|
||||
expect(stripHeartbeatToken(undefined)).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken(" ")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps content and removes token when mixed", () => {
|
||||
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
|
||||
shouldSkip: false,
|
||||
text: "ALERT",
|
||||
});
|
||||
expect(stripHeartbeatToken(`hello`)).toEqual({
|
||||
shouldSkip: false,
|
||||
text: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips repeated OK tails after heartbeat token", () => {
|
||||
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken("HEARTBEAT_OK_OK")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken("HEARTBEAT_OK _OK")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken("HEARTBEAT_OK OK")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
|
||||
shouldSkip: false,
|
||||
text: "ALERT",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves reply heartbeat interval from config and overrides", () => {
|
||||
const cfgBase: ClawdisConfig = {};
|
||||
expect(resolveReplyHeartbeatIntervalMs(cfgBase)).toBeNull();
|
||||
expect(
|
||||
resolveReplyHeartbeatIntervalMs({
|
||||
agent: { heartbeat: { every: "5m" } },
|
||||
}),
|
||||
).toBe(5 * 60_000);
|
||||
expect(
|
||||
resolveReplyHeartbeatIntervalMs({
|
||||
agent: { heartbeat: { every: "0m" } },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(resolveReplyHeartbeatIntervalMs(cfgBase, "7m")).toBe(7 * 60_000);
|
||||
expect(
|
||||
resolveReplyHeartbeatIntervalMs({
|
||||
agent: { heartbeat: { every: "5" } },
|
||||
}),
|
||||
).toBe(5 * 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHeartbeatRecipients", () => {
|
||||
it("returns the sole session recipient", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeSessionStore({
|
||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
||||
});
|
||||
const cfg: ClawdisConfig = {
|
||||
routing: {
|
||||
allowFrom: ["+1999"],
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
expect(result.source).toBe("session-single");
|
||||
expect(result.recipients).toEqual(["+1000"]);
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("surfaces ambiguity when multiple sessions exist", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeSessionStore({
|
||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
||||
alt: { updatedAt: now - 10, lastChannel: "whatsapp", lastTo: "+2000" },
|
||||
});
|
||||
const cfg: ClawdisConfig = {
|
||||
routing: {
|
||||
allowFrom: ["+1999"],
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
expect(result.source).toBe("session-ambiguous");
|
||||
expect(result.recipients).toEqual(["+1000", "+2000"]);
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("filters wildcard allowFrom when no sessions exist", async () => {
|
||||
const store = await makeSessionStore({});
|
||||
const cfg: ClawdisConfig = {
|
||||
routing: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg);
|
||||
expect(result.recipients).toHaveLength(0);
|
||||
expect(result.source).toBe("allowFrom");
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("merges sessions and allowFrom when --all is set", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeSessionStore({
|
||||
main: { updatedAt: now, lastChannel: "whatsapp", lastTo: "+1000" },
|
||||
});
|
||||
const cfg: ClawdisConfig = {
|
||||
routing: {
|
||||
allowFrom: ["+1999"],
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
};
|
||||
const result = resolveHeartbeatRecipients(cfg, { all: true });
|
||||
expect(result.source).toBe("all");
|
||||
expect(result.recipients.sort()).toEqual(["+1000", "+1999"].sort());
|
||||
await store.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("partial reply gating", () => {
|
||||
it("does not send partial replies for WhatsApp surface", async () => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -387,249 +239,6 @@ describe("partial reply gating", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWebHeartbeatOnce", () => {
|
||||
it("skips when heartbeat token returned", async () => {
|
||||
const store = await makeSessionStore();
|
||||
const sender: typeof sendMessageWhatsApp = vi.fn();
|
||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||
await runWebHeartbeatOnce({
|
||||
cfg: {
|
||||
routing: {
|
||||
allowFrom: ["+1555"],
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
to: "+1555",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
});
|
||||
expect(resolver).toHaveBeenCalled();
|
||||
expect(sender).not.toHaveBeenCalled();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("sends when alert text present", async () => {
|
||||
const store = await makeSessionStore();
|
||||
const sender: typeof sendMessageWhatsApp = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
||||
await runWebHeartbeatOnce({
|
||||
cfg: {
|
||||
routing: {
|
||||
allowFrom: ["+1555"],
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
to: "+1555",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
});
|
||||
expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false });
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("falls back to most recent session when no to is provided", async () => {
|
||||
const store = await makeSessionStore();
|
||||
const storePath = store.storePath;
|
||||
const sender: typeof sendMessageWhatsApp = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
||||
const now = Date.now();
|
||||
const sessionEntries = {
|
||||
"+1222": { sessionId: "s1", updatedAt: now - 1000 },
|
||||
"+1333": { sessionId: "s2", updatedAt: now },
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionEntries));
|
||||
await runWebHeartbeatOnce({
|
||||
cfg: {
|
||||
routing: {
|
||||
allowFrom: ["+1999"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
to: "+1999",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
});
|
||||
expect(sender).toHaveBeenCalledWith("+1999", "ALERT", { verbose: false });
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("does not refresh updatedAt when heartbeat is skipped", async () => {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdis-heartbeat-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const now = Date.now();
|
||||
const originalUpdated = now - 30 * 60 * 1000;
|
||||
const store = {
|
||||
"+1555": { sessionId: "sess1", updatedAt: originalUpdated },
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify(store));
|
||||
|
||||
const sender: typeof sendMessageWhatsApp = vi.fn();
|
||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||
setLoadConfigMock({
|
||||
routing: {
|
||||
allowFrom: ["+1555"],
|
||||
},
|
||||
session: {
|
||||
store: storePath,
|
||||
idleMinutes: 60,
|
||||
heartbeatIdleMinutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
await runWebHeartbeatOnce({
|
||||
to: "+1555",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
});
|
||||
|
||||
const after = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(after["+1555"].updatedAt).toBe(originalUpdated);
|
||||
expect(sender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("heartbeat reuses existing session id when last inbound is present", async () => {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdis-heartbeat-session-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const sessionId = "sess-keep";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
main: { sessionId, updatedAt: Date.now(), systemSent: false },
|
||||
}),
|
||||
);
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
allowFrom: ["+4367"],
|
||||
},
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
}));
|
||||
|
||||
const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN });
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
|
||||
const cfg: ClawdisConfig = {
|
||||
routing: {
|
||||
allowFrom: ["+4367"],
|
||||
},
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
};
|
||||
|
||||
await runWebHeartbeatOnce({
|
||||
cfg,
|
||||
to: "+4367",
|
||||
verbose: false,
|
||||
replyResolver,
|
||||
runtime,
|
||||
});
|
||||
|
||||
const heartbeatCall = replyResolver.mock.calls.find(
|
||||
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
|
||||
);
|
||||
expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("heartbeat honors session-id override and seeds store", async () => {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdis-heartbeat-override-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify({}));
|
||||
|
||||
const sessionId = "override-123";
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
allowFrom: ["+1999"],
|
||||
},
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
}));
|
||||
|
||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||
const cfg: ClawdisConfig = {
|
||||
routing: {
|
||||
allowFrom: ["+1999"],
|
||||
},
|
||||
session: { store: storePath, idleMinutes: 60 },
|
||||
};
|
||||
await runWebHeartbeatOnce({
|
||||
cfg,
|
||||
to: "+1999",
|
||||
verbose: false,
|
||||
replyResolver: resolver,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
const heartbeatCall = resolver.mock.calls.find(
|
||||
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
|
||||
);
|
||||
expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId);
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const stored = raw ? JSON.parse(raw) : {};
|
||||
expect(stored.main?.sessionId).toBe(sessionId);
|
||||
expect(stored.main?.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("sends overrideBody directly and skips resolver", async () => {
|
||||
const store = await makeSessionStore();
|
||||
const sender: typeof sendMessageWhatsApp = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||
const resolver = vi.fn();
|
||||
await runWebHeartbeatOnce({
|
||||
cfg: {
|
||||
routing: {
|
||||
allowFrom: ["+1555"],
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
to: "+1555",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
overrideBody: "manual ping",
|
||||
});
|
||||
expect(sender).toHaveBeenCalledWith("+1555", "manual ping", {
|
||||
verbose: false,
|
||||
});
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("dry-run overrideBody prints and skips send", async () => {
|
||||
const store = await makeSessionStore();
|
||||
const sender: typeof sendMessageWhatsApp = vi.fn();
|
||||
const resolver = vi.fn();
|
||||
await runWebHeartbeatOnce({
|
||||
cfg: {
|
||||
routing: {
|
||||
allowFrom: ["+1555"],
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
},
|
||||
to: "+1555",
|
||||
verbose: false,
|
||||
sender,
|
||||
replyResolver: resolver,
|
||||
overrideBody: "dry",
|
||||
dryRun: true,
|
||||
});
|
||||
expect(sender).not.toHaveBeenCalled();
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
await store.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -746,153 +355,6 @@ describe("web auto-reply", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("skips reply heartbeat when requests are running", async () => {
|
||||
const tmpDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdis-heartbeat-queue-"),
|
||||
);
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify({}));
|
||||
|
||||
const queueSpy = vi.spyOn(commandQueue, "getQueueSize").mockReturnValue(2);
|
||||
const replyResolver = vi.fn();
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
const onClose = new Promise<void>(() => {
|
||||
// stay open until aborted
|
||||
});
|
||||
return { close: vi.fn(), onClose };
|
||||
});
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
allowFrom: ["+1555"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
}));
|
||||
|
||||
const controller = new AbortController();
|
||||
const run = monitorWebProvider(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
replyResolver,
|
||||
runtime,
|
||||
controller.signal,
|
||||
{ replyHeartbeatEvery: "1m", replyHeartbeatNow: true },
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
await run;
|
||||
expect(replyResolver).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
queueSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to main recipient when last inbound is a group chat", async () => {
|
||||
const now = Date.now();
|
||||
const store = await makeSessionStore({
|
||||
main: {
|
||||
sessionId: "sid-main",
|
||||
updatedAt: now,
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
});
|
||||
|
||||
const replyResolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = vi.fn(
|
||||
async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
const onClose = new Promise<void>(() => {
|
||||
// stay open until aborted
|
||||
});
|
||||
return { close: vi.fn(), onClose };
|
||||
},
|
||||
);
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never;
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
routing: {
|
||||
allowFrom: ["+1555"],
|
||||
groupChat: { requireMention: true, mentionPatterns: ["@clawd"] },
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
}));
|
||||
|
||||
const controller = new AbortController();
|
||||
const run = monitorWebProvider(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
replyResolver,
|
||||
runtime,
|
||||
controller.signal,
|
||||
{ replyHeartbeatEvery: "10000m" },
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello group",
|
||||
from: "123@g.us",
|
||||
to: "+1555",
|
||||
id: "g1",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
chatType: "group",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
});
|
||||
|
||||
// No mention => no auto-reply for the group message.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(
|
||||
replyResolver.mock.calls.some(
|
||||
(call) => call[0]?.Body !== HEARTBEAT_PROMPT,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
requestReplyHeartbeatNow({ coalesceMs: 0 });
|
||||
let heartbeatCall = replyResolver.mock.calls.find(
|
||||
(call) =>
|
||||
call[0]?.Body === HEARTBEAT_PROMPT &&
|
||||
call[0]?.MessageSid === "sid-main",
|
||||
);
|
||||
const deadline = Date.now() + 1000;
|
||||
while (!heartbeatCall && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
heartbeatCall = replyResolver.mock.calls.find(
|
||||
(call) =>
|
||||
call[0]?.Body === HEARTBEAT_PROMPT &&
|
||||
call[0]?.MessageSid === "sid-main",
|
||||
);
|
||||
}
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(heartbeatCall).toBeDefined();
|
||||
expect(heartbeatCall?.[0]?.From).toBe("+1555");
|
||||
expect(heartbeatCall?.[0]?.To).toBe("+1555");
|
||||
expect(heartbeatCall?.[0]?.MessageSid).toBe("sid-main");
|
||||
} finally {
|
||||
controller.abort();
|
||||
await store.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("processes inbound messages without batching and preserves timestamps", async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "Europe/Vienna";
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
parseActivationCommand,
|
||||
} from "../auto-reply/group-activation.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import {
|
||||
HEARTBEAT_PROMPT,
|
||||
stripHeartbeatToken,
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { waitForever } from "../cli/wait.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
@@ -22,7 +25,6 @@ import { isVerbose, logVerbose } from "../globals.js";
|
||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
||||
import { getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
|
||||
import { setActiveWebListener } from "./active-listener.js";
|
||||
@@ -37,8 +39,6 @@ import {
|
||||
resolveReconnectPolicy,
|
||||
sleepWithAbort,
|
||||
} from "./reconnect.js";
|
||||
import type { ReplyHeartbeatWakeResult } from "./reply-heartbeat-wake.js";
|
||||
import { setReplyHeartbeatWakeHandler } from "./reply-heartbeat-wake.js";
|
||||
import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js";
|
||||
|
||||
const WEB_TEXT_LIMIT = 4000;
|
||||
@@ -48,11 +48,6 @@ const whatsappInboundLog = whatsappLog.child("inbound");
|
||||
const whatsappOutboundLog = whatsappLog.child("outbound");
|
||||
const whatsappHeartbeatLog = whatsappLog.child("heartbeat");
|
||||
|
||||
let heartbeatsEnabled = true;
|
||||
export function setHeartbeatsEnabled(enabled: boolean) {
|
||||
heartbeatsEnabled = enabled;
|
||||
}
|
||||
|
||||
// Send via the active gateway-backed listener. The monitor already owns the single
|
||||
// Baileys session, so use its send API directly.
|
||||
async function sendWithIpcFallback(
|
||||
@@ -73,8 +68,6 @@ type WebInboundMsg = Parameters<
|
||||
export type WebMonitorTuning = {
|
||||
reconnect?: Partial<ReconnectPolicy>;
|
||||
heartbeatSeconds?: number;
|
||||
replyHeartbeatEvery?: string;
|
||||
replyHeartbeatNow?: boolean;
|
||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
statusSink?: (status: WebProviderStatus) => void;
|
||||
};
|
||||
@@ -82,8 +75,7 @@ export type WebMonitorTuning = {
|
||||
const formatDuration = (ms: number) =>
|
||||
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||
|
||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
|
||||
export { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
|
||||
|
||||
export type WebProviderStatus = {
|
||||
running: boolean;
|
||||
@@ -188,41 +180,7 @@ function debugMention(
|
||||
return { wasMentioned: result, details };
|
||||
}
|
||||
|
||||
export function resolveReplyHeartbeatIntervalMs(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
overrideEvery?: string,
|
||||
) {
|
||||
const raw = overrideEvery ?? cfg.agent?.heartbeat?.every;
|
||||
if (!raw) return null;
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return null;
|
||||
let ms: number;
|
||||
try {
|
||||
ms = parseDurationMs(trimmed, { defaultUnit: "m" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (ms <= 0) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
export function stripHeartbeatToken(raw?: string) {
|
||||
if (!raw) return { shouldSkip: true, text: "" };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { shouldSkip: true, text: "" };
|
||||
if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" };
|
||||
const hadToken = trimmed.includes(HEARTBEAT_TOKEN);
|
||||
let withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim();
|
||||
if (hadToken && withoutToken) {
|
||||
// LLMs sometimes echo malformed HEARTBEAT_OK_OK... tails; strip trailing OK runs to avoid spam.
|
||||
withoutToken = withoutToken.replace(/[\s_]*OK(?:[\s_]*OK)*$/gi, "").trim();
|
||||
}
|
||||
const shouldSkip = withoutToken.length === 0;
|
||||
return {
|
||||
shouldSkip,
|
||||
text: shouldSkip ? "" : withoutToken || trimmed,
|
||||
};
|
||||
}
|
||||
export { stripHeartbeatToken };
|
||||
|
||||
function isSilentReply(payload?: ReplyPayload): boolean {
|
||||
if (!payload) return false;
|
||||
@@ -427,27 +385,6 @@ export async function runWebHeartbeatOnce(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackRecipient(cfg: ReturnType<typeof loadConfig>) {
|
||||
const sessionCfg = cfg.session;
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||
const main = store[mainKey];
|
||||
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
||||
const lastChannel = main?.lastChannel;
|
||||
|
||||
if (lastChannel === "whatsapp" && lastTo) {
|
||||
return normalizeE164(lastTo);
|
||||
}
|
||||
|
||||
const allowFrom =
|
||||
Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0
|
||||
? cfg.routing.allowFrom.filter((v) => v !== "*")
|
||||
: [];
|
||||
if (allowFrom.length === 0) return null;
|
||||
return allowFrom[0] ? normalizeE164(allowFrom[0]) : null;
|
||||
}
|
||||
|
||||
function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
||||
const sessionCfg = cfg.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
@@ -775,10 +712,6 @@ export async function monitorWebProvider(
|
||||
cfg,
|
||||
tuning.heartbeatSeconds,
|
||||
);
|
||||
const replyHeartbeatIntervalMs = resolveReplyHeartbeatIntervalMs(
|
||||
cfg,
|
||||
tuning.replyHeartbeatEvery,
|
||||
);
|
||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||
const mentionConfig = buildMentionConfig(cfg);
|
||||
const sessionStorePath = resolveStorePath(cfg.session?.store);
|
||||
@@ -940,7 +873,6 @@ export async function monitorWebProvider(
|
||||
const connectionId = newConnectionId();
|
||||
const startedAt = Date.now();
|
||||
let heartbeat: NodeJS.Timeout | null = null;
|
||||
let replyHeartbeatTimer: NodeJS.Timeout | null = null;
|
||||
let watchdogTimer: NodeJS.Timeout | null = null;
|
||||
let lastMessageAt: number | null = null;
|
||||
let handledMessages = 0;
|
||||
@@ -1346,9 +1278,7 @@ export async function monitorWebProvider(
|
||||
|
||||
const closeListener = async () => {
|
||||
setActiveWebListener(null);
|
||||
setReplyHeartbeatWakeHandler(null);
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer);
|
||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||
if (backgroundTasks.size > 0) {
|
||||
await Promise.allSettled(backgroundTasks);
|
||||
@@ -1363,7 +1293,6 @@ export async function monitorWebProvider(
|
||||
|
||||
if (keepAlive) {
|
||||
heartbeat = setInterval(() => {
|
||||
if (!heartbeatsEnabled) return;
|
||||
const authAgeMs = getWebAuthAgeMs();
|
||||
const minutesSinceLastMessage = lastMessageAt
|
||||
? Math.floor((Date.now() - lastMessageAt) / 60000)
|
||||
@@ -1420,240 +1349,6 @@ export async function monitorWebProvider(
|
||||
}, WATCHDOG_CHECK_MS);
|
||||
}
|
||||
|
||||
const runReplyHeartbeat = async (): Promise<ReplyHeartbeatWakeResult> => {
|
||||
const started = Date.now();
|
||||
if (!heartbeatsEnabled) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
const queued = getQueueSize();
|
||||
if (queued > 0) {
|
||||
heartbeatLogger.info(
|
||||
{ connectionId, reason: "requests-in-flight", queued },
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
if (isVerbose()) {
|
||||
whatsappHeartbeatLog.debug("heartbeat skipped (requests in flight)");
|
||||
}
|
||||
return { status: "skipped", reason: "requests-in-flight" };
|
||||
}
|
||||
if (!replyHeartbeatIntervalMs) {
|
||||
return { status: "skipped", reason: "disabled" };
|
||||
}
|
||||
let heartbeatInboundMsg = lastInboundMsg;
|
||||
if (heartbeatInboundMsg?.chatType === "group") {
|
||||
// Heartbeats should never target group chats. If the last inbound activity
|
||||
// was in a group, fall back to the main/direct session recipient instead
|
||||
// of skipping heartbeats entirely.
|
||||
heartbeatLogger.info(
|
||||
{ connectionId, reason: "last-inbound-group" },
|
||||
"reply heartbeat falling back",
|
||||
);
|
||||
heartbeatInboundMsg = null;
|
||||
}
|
||||
const tickStart = Date.now();
|
||||
if (!heartbeatInboundMsg) {
|
||||
const fallbackTo = getFallbackRecipient(cfg);
|
||||
if (!fallbackTo) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
reason: "no-recent-inbound",
|
||||
durationMs: Date.now() - tickStart,
|
||||
},
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
if (isVerbose()) {
|
||||
whatsappHeartbeatLog.debug("heartbeat skipped (no recent inbound)");
|
||||
}
|
||||
return { status: "skipped", reason: "no-recent-inbound" };
|
||||
}
|
||||
const snapshot = getSessionSnapshot(cfg, fallbackTo, true);
|
||||
if (!snapshot.entry) {
|
||||
heartbeatLogger.info(
|
||||
{ connectionId, to: fallbackTo, reason: "no-session-for-fallback" },
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
if (isVerbose()) {
|
||||
whatsappHeartbeatLog.debug(
|
||||
"heartbeat skipped (no session to resume)",
|
||||
);
|
||||
}
|
||||
return { status: "skipped", reason: "no-session-for-fallback" };
|
||||
}
|
||||
if (isVerbose()) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
to: fallbackTo,
|
||||
reason: "fallback-session",
|
||||
sessionId: snapshot.entry?.sessionId ?? null,
|
||||
sessionFresh: snapshot.fresh,
|
||||
},
|
||||
"reply heartbeat start",
|
||||
);
|
||||
}
|
||||
await runWebHeartbeatOnce({
|
||||
cfg,
|
||||
to: fallbackTo,
|
||||
verbose,
|
||||
replyResolver,
|
||||
sessionId: snapshot.entry.sessionId,
|
||||
});
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
to: fallbackTo,
|
||||
...snapshot,
|
||||
durationMs: Date.now() - tickStart,
|
||||
},
|
||||
"reply heartbeat sent (fallback session)",
|
||||
);
|
||||
return { status: "ran", durationMs: Date.now() - started };
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = getSessionSnapshot(cfg, heartbeatInboundMsg.from);
|
||||
if (isVerbose()) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
to: heartbeatInboundMsg.from,
|
||||
intervalMs: replyHeartbeatIntervalMs,
|
||||
sessionKey: snapshot.key,
|
||||
sessionId: snapshot.entry?.sessionId ?? null,
|
||||
sessionFresh: snapshot.fresh,
|
||||
},
|
||||
"reply heartbeat start",
|
||||
);
|
||||
}
|
||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||
{
|
||||
Body: HEARTBEAT_PROMPT,
|
||||
From: heartbeatInboundMsg.from,
|
||||
To: heartbeatInboundMsg.to,
|
||||
MessageSid: snapshot.entry?.sessionId,
|
||||
MediaPath: undefined,
|
||||
MediaUrl: undefined,
|
||||
MediaType: undefined,
|
||||
},
|
||||
{
|
||||
onReplyStart: heartbeatInboundMsg.sendComposing,
|
||||
isHeartbeat: true,
|
||||
},
|
||||
);
|
||||
|
||||
const replyPayload = Array.isArray(replyResult)
|
||||
? replyResult[0]
|
||||
: replyResult;
|
||||
|
||||
if (
|
||||
!replyPayload ||
|
||||
(!replyPayload.text &&
|
||||
!replyPayload.mediaUrl &&
|
||||
!replyPayload.mediaUrls?.length)
|
||||
) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
durationMs: Date.now() - tickStart,
|
||||
reason: "empty-reply",
|
||||
},
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
if (isVerbose()) {
|
||||
whatsappHeartbeatLog.debug("heartbeat ok (empty reply)");
|
||||
}
|
||||
return { status: "ran", durationMs: Date.now() - started };
|
||||
}
|
||||
|
||||
const stripped = stripHeartbeatToken(replyPayload.text);
|
||||
const hasMedia = Boolean(
|
||||
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
durationMs: Date.now() - tickStart,
|
||||
reason: "heartbeat-token",
|
||||
rawLength: replyPayload.text?.length ?? 0,
|
||||
},
|
||||
"reply heartbeat skipped",
|
||||
);
|
||||
if (isVerbose()) {
|
||||
whatsappHeartbeatLog.debug("heartbeat ok (HEARTBEAT_OK)");
|
||||
}
|
||||
return { status: "ran", durationMs: Date.now() - started };
|
||||
}
|
||||
|
||||
// Apply response prefix if configured (same as regular messages)
|
||||
let finalText = stripped.text;
|
||||
const responsePrefix = cfg.messages?.responsePrefix;
|
||||
if (
|
||||
responsePrefix &&
|
||||
finalText &&
|
||||
!finalText.startsWith(responsePrefix)
|
||||
) {
|
||||
finalText = `${responsePrefix} ${finalText}`;
|
||||
}
|
||||
|
||||
const cleanedReply: ReplyPayload = {
|
||||
...replyPayload,
|
||||
text: finalText,
|
||||
};
|
||||
|
||||
await deliverWebReply({
|
||||
replyResult: cleanedReply,
|
||||
msg: heartbeatInboundMsg,
|
||||
maxMediaBytes,
|
||||
replyLogger,
|
||||
connectionId,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - tickStart;
|
||||
whatsappHeartbeatLog.info(
|
||||
`heartbeat alert sent (${formatDuration(durationMs)})`,
|
||||
);
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
durationMs,
|
||||
hasMedia,
|
||||
chars: stripped.text?.length ?? 0,
|
||||
},
|
||||
"reply heartbeat sent",
|
||||
);
|
||||
return { status: "ran", durationMs: Date.now() - started };
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - tickStart;
|
||||
heartbeatLogger.warn(
|
||||
{
|
||||
connectionId,
|
||||
error: formatError(err),
|
||||
durationMs,
|
||||
},
|
||||
"reply heartbeat failed",
|
||||
);
|
||||
whatsappHeartbeatLog.warn(
|
||||
`heartbeat failed (${formatDuration(durationMs)})`,
|
||||
);
|
||||
return { status: "failed", reason: formatError(err) };
|
||||
}
|
||||
};
|
||||
|
||||
setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat());
|
||||
|
||||
if (replyHeartbeatIntervalMs && !replyHeartbeatTimer) {
|
||||
const intervalMs = replyHeartbeatIntervalMs;
|
||||
replyHeartbeatTimer = setInterval(() => {
|
||||
if (!heartbeatsEnabled) return;
|
||||
void runReplyHeartbeat();
|
||||
}, intervalMs);
|
||||
if (tuning.replyHeartbeatNow) {
|
||||
void runReplyHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
whatsappLog.info(
|
||||
"Listening for personal WhatsApp inbound messages. Ctrl+C to stop.",
|
||||
);
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
export type ReplyHeartbeatWakeResult =
|
||||
| { status: "ran"; durationMs: number }
|
||||
| { status: "skipped"; reason: string }
|
||||
| { status: "failed"; reason: string };
|
||||
|
||||
export type ReplyHeartbeatWakeHandler = (opts: {
|
||||
reason?: string;
|
||||
}) => Promise<ReplyHeartbeatWakeResult>;
|
||||
|
||||
let handler: ReplyHeartbeatWakeHandler | null = null;
|
||||
let pendingReason: string | null = null;
|
||||
let scheduled = false;
|
||||
let running = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
const DEFAULT_COALESCE_MS = 250;
|
||||
const DEFAULT_RETRY_MS = 1_000;
|
||||
|
||||
function schedule(coalesceMs: number) {
|
||||
if (timer) return;
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
scheduled = false;
|
||||
const active = handler;
|
||||
if (!active) return;
|
||||
if (running) {
|
||||
scheduled = true;
|
||||
schedule(coalesceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = pendingReason;
|
||||
pendingReason = null;
|
||||
running = true;
|
||||
try {
|
||||
const res = await active({ reason: reason ?? undefined });
|
||||
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
||||
// The main lane is busy; retry soon.
|
||||
pendingReason = reason ?? "retry";
|
||||
schedule(DEFAULT_RETRY_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
pendingReason = reason ?? "retry";
|
||||
schedule(DEFAULT_RETRY_MS);
|
||||
throw err;
|
||||
} finally {
|
||||
running = false;
|
||||
if (pendingReason || scheduled) schedule(coalesceMs);
|
||||
}
|
||||
}, coalesceMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export function setReplyHeartbeatWakeHandler(
|
||||
next: ReplyHeartbeatWakeHandler | null,
|
||||
) {
|
||||
handler = next;
|
||||
if (handler && pendingReason) {
|
||||
schedule(DEFAULT_COALESCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function requestReplyHeartbeatNow(opts?: {
|
||||
reason?: string;
|
||||
coalesceMs?: number;
|
||||
}) {
|
||||
pendingReason = opts?.reason ?? pendingReason ?? "requested";
|
||||
schedule(opts?.coalesceMs ?? DEFAULT_COALESCE_MS);
|
||||
}
|
||||
|
||||
export function hasReplyHeartbeatWakeHandler() {
|
||||
return handler !== null;
|
||||
}
|
||||
|
||||
export function hasPendingReplyHeartbeatWake() {
|
||||
return pendingReason !== null || Boolean(timer) || scheduled;
|
||||
}
|
||||
Reference in New Issue
Block a user