From 9da5b9f4bbd514e68cec11a0b3c2f422b5072fd0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 3 Dec 2025 00:40:19 +0000 Subject: [PATCH] Heartbeat: normalize array replies --- src/auto-reply/reply.ts | 16 +++++++++++-- src/auto-reply/types.ts | 1 + src/web/auto-reply.ts | 43 +++++++++++++++++++++-------------- src/web/inbound.media.test.ts | 7 +++++- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 2d6a26fc8..2cb0ff057 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -310,11 +310,23 @@ export async function getReplyFromConfig( return result; } - if (reply && reply.mode === "command" && reply.command?.length) { + const isHeartbeat = opts?.isHeartbeat === true; + + if (reply && reply.mode === "command") { + const commandArgs = + isHeartbeat && reply.heartbeatCommand?.length + ? reply.heartbeatCommand + : reply.command; + + if (!commandArgs?.length) { + cleanupTyping(); + return undefined; + } + await onReplyStart(); const commandReply = { ...reply, - command: reply.command, + command: commandArgs, mode: "command" as const, }; try { diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 145beb98e..211d1f932 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -1,5 +1,6 @@ export type GetReplyOptions = { onReplyStart?: () => Promise | void; + isHeartbeat?: boolean; }; export type ReplyPayload = { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 3c0d323e1..bf240853e 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -191,14 +191,18 @@ export async function runWebHeartbeatOnce(opts: { To: to, MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId, }, - undefined, + { isHeartbeat: true }, cfg, ); + const replyPayload = Array.isArray(replyResult) + ? replyResult[0] + : replyResult; + if ( - !replyResult || - (!replyResult.text && - !replyResult.mediaUrl && - !replyResult.mediaUrls?.length) + !replyPayload || + (!replyPayload.text && + !replyPayload.mediaUrl && + !replyPayload.mediaUrls?.length) ) { heartbeatLogger.info( { @@ -213,9 +217,9 @@ export async function runWebHeartbeatOnce(opts: { } const hasMedia = Boolean( - replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0, + replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0, ); - const stripped = stripHeartbeatToken(replyResult.text); + const stripped = stripHeartbeatToken(replyPayload.text); if (stripped.shouldSkip && !hasMedia) { // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. const sessionCfg = cfg.inbound?.reply?.session; @@ -227,7 +231,7 @@ export async function runWebHeartbeatOnce(opts: { } heartbeatLogger.info( - { to, reason: "heartbeat-token", rawLength: replyResult.text?.length }, + { to, reason: "heartbeat-token", rawLength: replyPayload.text?.length }, "heartbeat skipped", ); console.log(success("heartbeat: ok (HEARTBEAT_OK)")); @@ -241,7 +245,7 @@ export async function runWebHeartbeatOnce(opts: { ); } - const finalText = stripped.text || replyResult.text || ""; + const finalText = stripped.text || replyPayload.text || ""; if (dryRun) { heartbeatLogger.info( { to, reason: "dry-run", chars: finalText.length }, @@ -963,14 +967,19 @@ export async function monitorWebProvider( }, { onReplyStart: lastInboundMsg.sendComposing, + isHeartbeat: true, }, ); + const replyPayload = Array.isArray(replyResult) + ? replyResult[0] + : replyResult; + if ( - !replyResult || - (!replyResult.text && - !replyResult.mediaUrl && - !replyResult.mediaUrls?.length) + !replyPayload || + (!replyPayload.text && + !replyPayload.mediaUrl && + !replyPayload.mediaUrls?.length) ) { heartbeatLogger.info( { @@ -984,9 +993,9 @@ export async function monitorWebProvider( return; } - const stripped = stripHeartbeatToken(replyResult.text); + const stripped = stripHeartbeatToken(replyPayload.text); const hasMedia = Boolean( - replyResult.mediaUrl || (replyResult.mediaUrls?.length ?? 0) > 0, + replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0, ); if (stripped.shouldSkip && !hasMedia) { heartbeatLogger.info( @@ -994,7 +1003,7 @@ export async function monitorWebProvider( connectionId, durationMs: Date.now() - tickStart, reason: "heartbeat-token", - rawLength: replyResult.text?.length ?? 0, + rawLength: replyPayload.text?.length ?? 0, }, "reply heartbeat skipped", ); @@ -1014,7 +1023,7 @@ export async function monitorWebProvider( } const cleanedReply: ReplyPayload = { - ...replyResult, + ...replyPayload, text: finalText, }; diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index f0205b27e..b8d60f0af 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -100,7 +100,12 @@ describe("web inbound media saves with extension", () => { }; realSock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setTimeout(resolve, 5)); + + // Allow a brief window for the async handler to fire on slower runners. + for (let i = 0; i < 10; i++) { + if (onMessage.mock.calls.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } expect(onMessage).toHaveBeenCalledTimes(1); const msg = onMessage.mock.calls[0][0];