From fce4a1d62fb91ebd62b7dbe202e17519efff0711 Mon Sep 17 00:00:00 2001 From: Dale Date: Sun, 1 Mar 2026 18:52:14 -0500 Subject: [PATCH 01/12] feat(signal): implement instant ACK reactions for reactionLevel=ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When channels.signal.reactionLevel is set to 'ack', send an instant ACK reaction (configurable via messages.ackReaction) on inbound Signal messages before agent processing begins, giving users immediate visual feedback that their message was received. The reaction fires synchronously before the inbound debouncer, gated by messages.ackReactionScope (all/direct/group-all/group-mentions/off) via the existing shouldAckReaction() helper in channels/ack-reactions.ts. Fire-and-forget — never blocks the processing pipeline. Closes #20393 --- extensions/signal/src/monitor/ack-reaction.ts | 58 ++++++ .../event-handler.ack-reaction.test.ts | 192 ++++++++++++++++++ .../signal/src/monitor/event-handler.ts | 14 ++ 3 files changed, 264 insertions(+) create mode 100644 extensions/signal/src/monitor/ack-reaction.ts create mode 100644 extensions/signal/src/monitor/event-handler.ack-reaction.test.ts diff --git a/extensions/signal/src/monitor/ack-reaction.ts b/extensions/signal/src/monitor/ack-reaction.ts new file mode 100644 index 00000000000..40e261b7126 --- /dev/null +++ b/extensions/signal/src/monitor/ack-reaction.ts @@ -0,0 +1,58 @@ +/** + * Sends an instant ACK reaction on inbound Signal messages when reactionLevel === "ack". + * Call this BEFORE inboundDebouncer.enqueue() in event-handler.ts. + */ + +import { shouldAckReaction } from "../../channels/ack-reactions.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import { resolveSignalReactionLevel } from "../reaction-level.js"; +import { sendReactionSignal } from "../send-reactions.js"; + +export function maybeSendSignalAckReaction(params: { + cfg: OpenClawConfig; + senderRecipient: string; + targetTimestamp: number; + isGroup: boolean; + groupId?: string; + baseUrl: string; + account?: string; + accountId: string; +}): void { + const { ackEnabled } = resolveSignalReactionLevel({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!ackEnabled) { + return; + } + + const emoji = (params.cfg.messages?.ackReaction ?? "👀").trim(); + if (!emoji) { + return; + } + + const scope = params.cfg.messages?.ackReactionScope; + const shouldSend = shouldAckReaction({ + scope, + isDirect: !params.isGroup, + isGroup: params.isGroup, + isMentionableGroup: false, + requireMention: false, + canDetectMention: false, + effectiveWasMentioned: false, + shouldBypassMention: false, + }); + if (!shouldSend) { + return; + } + + sendReactionSignal(params.senderRecipient, params.targetTimestamp, emoji, { + baseUrl: params.baseUrl, + account: params.account, + accountId: params.accountId, + groupId: params.groupId, + }).catch((err) => { + logVerbose(`Signal ack reaction failed: ${String(err)}`); + }); +} diff --git a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts new file mode 100644 index 00000000000..e8bdbb25434 --- /dev/null +++ b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../send-reactions.js", () => ({ + sendReactionSignal: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock("../../auto-reply/dispatch.js", () => ({ + dispatchInboundMessage: vi.fn().mockResolvedValue({ queuedFinal: false }), +})); + +import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; +import { sendReactionSignal } from "../send-reactions.js"; +import { createSignalEventHandler } from "./event-handler.js"; +import { + createBaseSignalEventHandlerDeps, + createSignalReceiveEvent, +} from "./event-handler.test-harness.js"; + +function makeDeps(cfgOverrides: Record = {}) { + const accountOverrides = (cfgOverrides.accountOverrides as Record) ?? undefined; + const messagesOverrides = (cfgOverrides.messages as Record) ?? undefined; + return createBaseSignalEventHandlerDeps({ + cfg: { + channels: { + signal: { + accounts: { + default: { + reactionLevel: "ack", + ...accountOverrides, + }, + }, + }, + }, + messages: { + ackReaction: "👀", + ackReactionScope: "all", + ...messagesOverrides, + }, + ...cfgOverrides, + }, + ignoreAttachments: true, + }); +} + +function makeEvent(overrides: Record = {}) { + return createSignalReceiveEvent({ + dataMessage: { message: "hello", timestamp: 1700000000000 }, + ...overrides, + }); +} + +describe("Signal ACK reactions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends ack reaction for DM when reactionLevel=ack and scope=direct", async () => { + const deps = makeDeps({ + messages: { ackReaction: "👀", ackReactionScope: "direct" }, + }); + const handler = createSignalEventHandler(deps); + await handler(makeEvent()); + + expect(sendReactionSignal).toHaveBeenCalledWith( + "+15550001111", + 1700000000000, + "👀", + expect.objectContaining({ accountId: "default" }), + ); + }); + + it("sends ack reaction for DM when scope=all", async () => { + const deps = makeDeps({ + messages: { ackReaction: "👀", ackReactionScope: "all" }, + }); + const handler = createSignalEventHandler(deps); + await handler(makeEvent()); + + expect(sendReactionSignal).toHaveBeenCalledOnce(); + }); + + it("sends ack reaction for group when scope=all", async () => { + const deps = makeDeps({ + messages: { ackReaction: "👀", ackReactionScope: "all" }, + }); + const handler = createSignalEventHandler(deps); + await handler( + makeEvent({ + dataMessage: { + message: "hello group", + timestamp: 1700000000000, + groupInfo: { groupId: "grp123", groupName: "Test Group" }, + }, + }), + ); + + expect(sendReactionSignal).toHaveBeenCalledWith( + "+15550001111", + 1700000000000, + "👀", + expect.objectContaining({ groupId: "grp123" }), + ); + }); + + it("sends ack reaction for group when scope=group-all", async () => { + const deps = makeDeps({ + messages: { ackReaction: "👀", ackReactionScope: "group-all" }, + }); + const handler = createSignalEventHandler(deps); + await handler( + makeEvent({ + dataMessage: { + message: "hello group", + timestamp: 1700000000000, + groupInfo: { groupId: "grp123", groupName: "Test Group" }, + }, + }), + ); + + expect(sendReactionSignal).toHaveBeenCalledOnce(); + }); + + it("does NOT send ack when scope=off", async () => { + const deps = makeDeps({ + messages: { ackReaction: "👀", ackReactionScope: "off" }, + }); + const handler = createSignalEventHandler(deps); + await handler(makeEvent()); + + expect(sendReactionSignal).not.toHaveBeenCalled(); + }); + + it("does NOT send ack when reactionLevel=minimal", async () => { + const deps = makeDeps({ + accountOverrides: { reactionLevel: "minimal" }, + }); + const handler = createSignalEventHandler(deps); + await handler(makeEvent()); + + expect(sendReactionSignal).not.toHaveBeenCalled(); + }); + + it("does NOT send ack when reactionLevel=off", async () => { + const deps = makeDeps({ + accountOverrides: { reactionLevel: "off" }, + }); + const handler = createSignalEventHandler(deps); + await handler(makeEvent()); + + expect(sendReactionSignal).not.toHaveBeenCalled(); + }); + + it("does NOT send ack when timestamp is missing", async () => { + const deps = makeDeps(); + const handler = createSignalEventHandler(deps); + await handler( + makeEvent({ + timestamp: undefined, + dataMessage: { message: "hello", timestamp: 1700000000000 }, + }), + ); + + expect(sendReactionSignal).not.toHaveBeenCalled(); + }); + + it("sends ack BEFORE dispatch", async () => { + const callOrder: string[] = []; + vi.mocked(sendReactionSignal).mockImplementation(async () => { + callOrder.push("ack"); + return { ok: true }; + }); + vi.mocked(dispatchInboundMessage).mockImplementation(async () => { + callOrder.push("dispatch"); + return { queuedFinal: false } as ReturnType extends Promise< + infer T + > + ? T + : never; + }); + + const deps = makeDeps(); + const handler = createSignalEventHandler(deps); + await handler(makeEvent()); + + expect(callOrder[0]).toBe("ack"); + expect(callOrder).toContain("dispatch"); + }); +}); diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 36eb0e8d276..85328b24082 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -52,6 +52,7 @@ import { } from "../identity.js"; import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js"; import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js"; +import { maybeSendSignalAckReaction } from "./ack-reaction.js"; import type { SignalEnvelope, SignalEventHandlerDeps, @@ -781,6 +782,19 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const senderName = envelope.sourceName ?? senderDisplay; const messageId = typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined; + if (typeof envelope.timestamp === "number") { + maybeSendSignalAckReaction({ + cfg: deps.cfg, + senderRecipient, + targetTimestamp: envelope.timestamp, + isGroup, + groupId, + baseUrl: deps.baseUrl, + account: deps.account, + accountId: deps.accountId, + }); + } + await inboundDebouncer.enqueue({ senderName, senderDisplay, From 1910d66cbab8f2967f45bdd22ad1dad46e0c0bfd Mon Sep 17 00:00:00 2001 From: Dale Date: Sun, 1 Mar 2026 18:59:05 -0500 Subject: [PATCH 02/12] fix(signal): pass real mention state into ACK scope gating group-mentions scope was broken because isMentionableGroup, requireMention, canDetectMention and effectiveWasMentioned were all hardcoded to false. Pass real values from the event handler: wasMentioned (effectiveWasMentioned), canDetectMention (mentionRegexes.length > 0), and requireMention. Also add a test asserting group-mentions does not ack when no mention patterns are configured (the common case). --- extensions/signal/src/monitor/ack-reaction.ts | 19 ++++++++++++++----- .../event-handler.ack-reaction.test.ts | 19 +++++++++++++++++++ .../signal/src/monitor/event-handler.ts | 3 +++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/extensions/signal/src/monitor/ack-reaction.ts b/extensions/signal/src/monitor/ack-reaction.ts index 40e261b7126..8daa523ed3a 100644 --- a/extensions/signal/src/monitor/ack-reaction.ts +++ b/extensions/signal/src/monitor/ack-reaction.ts @@ -15,6 +15,12 @@ export function maybeSendSignalAckReaction(params: { targetTimestamp: number; isGroup: boolean; groupId?: string; + /** Whether the agent was mentioned in the message (for group-mentions scope gating). */ + wasMentioned?: boolean; + /** Whether mention detection is configured (i.e. mention patterns exist). */ + canDetectMention?: boolean; + /** Whether group requires a mention before responding (for group-mentions scope gating). */ + requireMention?: boolean; baseUrl: string; account?: string; accountId: string; @@ -33,15 +39,18 @@ export function maybeSendSignalAckReaction(params: { } const scope = params.cfg.messages?.ackReactionScope; + const canDetectMention = params.canDetectMention ?? false; + const requireMention = params.requireMention ?? false; + const wasMentioned = params.wasMentioned ?? false; const shouldSend = shouldAckReaction({ scope, isDirect: !params.isGroup, isGroup: params.isGroup, - isMentionableGroup: false, - requireMention: false, - canDetectMention: false, - effectiveWasMentioned: false, - shouldBypassMention: false, + isMentionableGroup: params.isGroup && canDetectMention, + requireMention, + canDetectMention, + effectiveWasMentioned: wasMentioned, + shouldBypassMention: !requireMention, }); if (!shouldSend) { return; diff --git a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts index e8bdbb25434..da97e9d8547 100644 --- a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts +++ b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts @@ -134,6 +134,25 @@ describe("Signal ACK reactions", () => { expect(sendReactionSignal).not.toHaveBeenCalled(); }); + it("does NOT send ack for group when scope=group-mentions and not mentioned", async () => { + const deps = makeDeps({ + messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + }); + const handler = createSignalEventHandler(deps); + await handler( + makeEvent({ + dataMessage: { + message: "hello group no mention", + timestamp: 1700000000000, + groupInfo: { groupId: "grp123", groupName: "Test Group" }, + }, + }), + ); + + // group-mentions requires a mention pattern match — no mention patterns configured so no ack + expect(sendReactionSignal).not.toHaveBeenCalled(); + }); + it("does NOT send ack when reactionLevel=minimal", async () => { const deps = makeDeps({ accountOverrides: { reactionLevel: "minimal" }, diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 85328b24082..7d49925c744 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -789,6 +789,9 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { targetTimestamp: envelope.timestamp, isGroup, groupId, + wasMentioned: effectiveWasMentioned, + canDetectMention: mentionRegexes.length > 0, + requireMention: Boolean(requireMention), baseUrl: deps.baseUrl, account: deps.account, accountId: deps.accountId, From 535b09da5223262e1c0371a0b08b8b83d9ccf6f4 Mon Sep 17 00:00:00 2001 From: Dale Date: Mon, 2 Mar 2026 00:21:43 -0500 Subject: [PATCH 03/12] fix(signal): use resolveAckReaction fallback chain for ACK emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded '👀' fallback with resolveAckReaction() from agents/identity.ts, which respects the full fallback chain: account-level → channel-level → agent identity → global messages.ackReaction → default. Addresses Codex P2 review comment on #31078. --- extensions/signal/src/monitor/ack-reaction.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/signal/src/monitor/ack-reaction.ts b/extensions/signal/src/monitor/ack-reaction.ts index 8daa523ed3a..0ea27749f07 100644 --- a/extensions/signal/src/monitor/ack-reaction.ts +++ b/extensions/signal/src/monitor/ack-reaction.ts @@ -3,6 +3,7 @@ * Call this BEFORE inboundDebouncer.enqueue() in event-handler.ts. */ +import { resolveAckReaction } from "../../agents/identity.js"; import { shouldAckReaction } from "../../channels/ack-reactions.js"; import type { OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; @@ -33,7 +34,10 @@ export function maybeSendSignalAckReaction(params: { return; } - const emoji = (params.cfg.messages?.ackReaction ?? "👀").trim(); + const emoji = resolveAckReaction(params.cfg, "main", { + channel: "signal", + accountId: params.accountId, + }).trim(); if (!emoji) { return; } From 2e22699b29e3be10a7e702bcfb98a59ccfe427ef Mon Sep 17 00:00:00 2001 From: Dale Date: Mon, 2 Mar 2026 01:12:08 -0500 Subject: [PATCH 04/12] fix(signal): fire ACK reaction before awaited I/O (attachment fetch, read receipt) Move maybeSendSignalAckReaction() call to before the attachment download and read-receipt send, both of which are awaited network calls that can delay the reaction by hundreds of milliseconds on slow connections. The ACK now fires immediately after access/mention gating confirms the message will be processed, giving users instant visual feedback. Addresses Codex P2 review comment on #31078. --- .../signal/src/monitor/event-handler.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 7d49925c744..0518c616efd 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -702,6 +702,24 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { return; } + // Send ACK reaction immediately — before any awaited I/O (attachment fetch, read receipt) + // so the user gets instant visual feedback that their message was received. + if (typeof envelope.timestamp === "number") { + maybeSendSignalAckReaction({ + cfg: deps.cfg, + senderRecipient, + targetTimestamp: envelope.timestamp, + isGroup, + groupId, + wasMentioned: effectiveWasMentioned, + canDetectMention: mentionRegexes.length > 0, + requireMention: Boolean(requireMention), + baseUrl: deps.baseUrl, + account: deps.account, + accountId: deps.accountId, + }); + } + let mediaPath: string | undefined; let mediaType: string | undefined; const mediaPaths: string[] = []; @@ -782,22 +800,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const senderName = envelope.sourceName ?? senderDisplay; const messageId = typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined; - if (typeof envelope.timestamp === "number") { - maybeSendSignalAckReaction({ - cfg: deps.cfg, - senderRecipient, - targetTimestamp: envelope.timestamp, - isGroup, - groupId, - wasMentioned: effectiveWasMentioned, - canDetectMention: mentionRegexes.length > 0, - requireMention: Boolean(requireMention), - baseUrl: deps.baseUrl, - account: deps.account, - accountId: deps.accountId, - }); - } - await inboundDebouncer.enqueue({ senderName, senderDisplay, From 8105516b303c3ba5afd814d85225382320517629 Mon Sep 17 00:00:00 2001 From: Dale Date: Mon, 2 Mar 2026 02:11:55 -0500 Subject: [PATCH 05/12] fix(signal): pass routed agentId to resolveAckReaction Hardcoded 'main' caused wrong identity emoji in multi-agent Signal setups. Pass route.agentId so the fallback chain resolves correctly for the actual responding agent. --- extensions/signal/src/monitor/ack-reaction.ts | 3 ++- extensions/signal/src/monitor/event-handler.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/signal/src/monitor/ack-reaction.ts b/extensions/signal/src/monitor/ack-reaction.ts index 0ea27749f07..7e8785abfff 100644 --- a/extensions/signal/src/monitor/ack-reaction.ts +++ b/extensions/signal/src/monitor/ack-reaction.ts @@ -12,6 +12,7 @@ import { sendReactionSignal } from "../send-reactions.js"; export function maybeSendSignalAckReaction(params: { cfg: OpenClawConfig; + agentId: string; senderRecipient: string; targetTimestamp: number; isGroup: boolean; @@ -34,7 +35,7 @@ export function maybeSendSignalAckReaction(params: { return; } - const emoji = resolveAckReaction(params.cfg, "main", { + const emoji = resolveAckReaction(params.cfg, params.agentId, { channel: "signal", accountId: params.accountId, }).trim(); diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 0518c616efd..7bb7492c23c 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -707,6 +707,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { if (typeof envelope.timestamp === "number") { maybeSendSignalAckReaction({ cfg: deps.cfg, + agentId: route.agentId, senderRecipient, targetTimestamp: envelope.timestamp, isGroup, From cab7a766fb413f7fb77c168b26d0c24390618448 Mon Sep 17 00:00:00 2001 From: Dale Date: Mon, 2 Mar 2026 06:41:48 -0500 Subject: [PATCH 06/12] fix(signal): gate ACK reaction on non-empty early body check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before sending the ACK reaction, verify the message has at least one of: non-empty trimmed text, an attachment, or a quote. Without this guard a whitespace-only message (bodyText === '' after trim) would receive an ACK even though it is dropped by the later bodyText guard, giving users a false 'message accepted' signal. Adds two test cases: - whitespace-only message → no ACK - empty text but has attachment → ACK sent Addresses: https://github.com/openclaw/openclaw/pull/31078#discussion_r2871918100 --- .../event-handler.ack-reaction.test.ts | 28 +++++++++++++++++++ .../signal/src/monitor/event-handler.ts | 8 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts index da97e9d8547..af6ef328601 100644 --- a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts +++ b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts @@ -186,6 +186,34 @@ describe("Signal ACK reactions", () => { expect(sendReactionSignal).not.toHaveBeenCalled(); }); + it("does NOT send ack when message body is empty (whitespace-only text, no attachment, no quote)", async () => { + const deps = makeDeps(); + const handler = createSignalEventHandler(deps); + await handler( + makeEvent({ + dataMessage: { message: " ", timestamp: 1700000000000 }, + }), + ); + + expect(sendReactionSignal).not.toHaveBeenCalled(); + }); + + it("sends ack when message has no text but has an attachment", async () => { + const deps = makeDeps(); + const handler = createSignalEventHandler(deps); + await handler( + makeEvent({ + dataMessage: { + message: "", + timestamp: 1700000000000, + attachments: [{ id: "att1", contentType: "image/png", size: 1024 }], + }, + }), + ); + + expect(sendReactionSignal).toHaveBeenCalledTimes(1); + }); + it("sends ack BEFORE dispatch", async () => { const callOrder: string[] = []; vi.mocked(sendReactionSignal).mockImplementation(async () => { diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 7bb7492c23c..b571e5f50f1 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -702,9 +702,15 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { return; } + // Early body guard: if the message has no text, no attachment, and no quote it will be + // dropped at the bodyText check below — don't send a false ACK in that case. + const hasEarlyBody = Boolean( + messageText || dataMessage.attachments?.length || dataMessage.quote?.text?.trim(), + ); + // Send ACK reaction immediately — before any awaited I/O (attachment fetch, read receipt) // so the user gets instant visual feedback that their message was received. - if (typeof envelope.timestamp === "number") { + if (hasEarlyBody && typeof envelope.timestamp === "number") { maybeSendSignalAckReaction({ cfg: deps.cfg, agentId: route.agentId, From 8a11d0ea32a8ee6514febc88c83c92ee3561c2ca Mon Sep 17 00:00:00 2001 From: Dale Date: Mon, 2 Mar 2026 07:38:54 -0500 Subject: [PATCH 07/12] fix(signal): fall back to dataMessage.timestamp for ACK reaction Mirror the same timestamp fallback already used for read receipts: envelope.timestamp ?? dataMessage.timestamp Messages that carry only dataMessage.timestamp (no envelope.timestamp) previously skipped the ACK entirely; they now receive one correctly. Updates the 'timestamp missing' test to require both timestamps absent, and adds a new test confirming the dataMessage fallback path. Addresses: https://github.com/openclaw/openclaw/pull/31078#discussion_r2871998637 --- .../event-handler.ack-reaction.test.ts | 23 +++++++++++++++++-- .../signal/src/monitor/event-handler.ts | 12 ++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts index af6ef328601..ef6f20d9e21 100644 --- a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts +++ b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts @@ -173,13 +173,32 @@ describe("Signal ACK reactions", () => { expect(sendReactionSignal).not.toHaveBeenCalled(); }); - it("does NOT send ack when timestamp is missing", async () => { + it("sends ack using dataMessage.timestamp when envelope.timestamp is missing", async () => { const deps = makeDeps(); const handler = createSignalEventHandler(deps); await handler( makeEvent({ timestamp: undefined, - dataMessage: { message: "hello", timestamp: 1700000000000 }, + dataMessage: { message: "hello", timestamp: 1700000001234 }, + }), + ); + + expect(sendReactionSignal).toHaveBeenCalledTimes(1); + expect(sendReactionSignal).toHaveBeenCalledWith( + expect.any(String), + 1700000001234, + expect.any(String), + expect.any(Object), + ); + }); + + it("does NOT send ack when both envelope and dataMessage timestamps are missing", async () => { + const deps = makeDeps(); + const handler = createSignalEventHandler(deps); + await handler( + makeEvent({ + timestamp: undefined, + dataMessage: { message: "hello", timestamp: undefined }, }), ); diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index b571e5f50f1..499d0d845ff 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -708,14 +708,22 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { messageText || dataMessage.attachments?.length || dataMessage.quote?.text?.trim(), ); + // Resolve ACK timestamp — mirror the same fallback used for read receipts. + const ackTimestamp = + typeof envelope.timestamp === "number" + ? envelope.timestamp + : typeof dataMessage.timestamp === "number" + ? dataMessage.timestamp + : undefined; + // Send ACK reaction immediately — before any awaited I/O (attachment fetch, read receipt) // so the user gets instant visual feedback that their message was received. - if (hasEarlyBody && typeof envelope.timestamp === "number") { + if (hasEarlyBody && ackTimestamp !== undefined) { maybeSendSignalAckReaction({ cfg: deps.cfg, agentId: route.agentId, senderRecipient, - targetTimestamp: envelope.timestamp, + targetTimestamp: ackTimestamp, isGroup, groupId, wasMentioned: effectiveWasMentioned, From 292a8ef9c40666b18f4ea631473cc2dc87228af3 Mon Sep 17 00:00:00 2001 From: Dale Date: Mon, 2 Mar 2026 13:16:17 -0500 Subject: [PATCH 08/12] fix(signal): silence dispatch from blank reaction envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: mediaKindFromMime(undefined) returns "unknown" which is truthy, so the placeholder block was unconditionally setting placeholder = "" on every message where no attachment was fetched. This caused blank-body dataMessages (e.g. Signal emoji reactions delivered by signal-cli without a reaction sub-field) to be dispatched to the agent with as the body text. Fix: only invoke mediaKindFromMime when mediaType is actually defined. When kind resolves to "unknown" or no attachment is present, fall back to "" (or empty when no attachment at all). Also adds hasBareReactionField guard: if dataMessage.reaction is present but isSignalReactionMessage() returned null, surface it as a system event rather than leaking through. Reaction-removals silently dropped. Mixed-content messages pass through normally. UAT confirmed: 👍 long-press reaction on a DM is now silently dropped with no response dispatched to the agent. 7 tests: well-formed reactions, bare reactions emitting system events, timestamp inclusion, reaction-removals, mixed content, plain messages, reaction with null-contentType attachment. --- .../event-handler.emoji-reactions.test.ts | 247 ++++++++++++++++++ .../signal/src/monitor/event-handler.ts | 103 +++++++- 2 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts diff --git a/extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts b/extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts new file mode 100644 index 00000000000..8ece0f6a672 --- /dev/null +++ b/extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts @@ -0,0 +1,247 @@ +/** + * Tests for Signal emoji reaction handling. + * + * When a remote user reacts to a message with an emoji (👍, ❤️, etc.), + * signal-cli sends a dataMessage with a `reaction` field. These should be + * surfaced as system events to the agent session (matching Discord's pattern) + * rather than leaking through as . + * + * Two paths: + * 1. Well-formed reactions (isSignalReactionMessage returns true) → + * handled by existing handleReactionOnlyInbound + * 2. Bare/malformed reactions (isSignalReactionMessage returns false, + * e.g. targetAuthor absent) → new hasBareReactionField guard surfaces + * as system event + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createSignalEventHandler } from "./event-handler.js"; +import { + createBaseSignalEventHandlerDeps, + createSignalReceiveEvent, +} from "./event-handler.test-harness.js"; + +const { dispatchInboundMessageMock, enqueueSystemEventMock } = vi.hoisted(() => ({ + dispatchInboundMessageMock: vi.fn().mockResolvedValue({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + }), + enqueueSystemEventMock: vi.fn(), +})); + +vi.mock("../send.js", () => ({ + sendMessageSignal: vi.fn(), + sendTypingSignal: vi.fn().mockResolvedValue(true), + sendReadReceiptSignal: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: dispatchInboundMessageMock, + dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock, + }; +}); + +vi.mock("../../infra/system-events.js", () => ({ + enqueueSystemEvent: enqueueSystemEventMock, +})); + +vi.mock("../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: vi.fn().mockResolvedValue([]), + upsertChannelPairingRequest: vi.fn(), +})); + +describe("signal createSignalEventHandler emoji reaction handling", () => { + beforeEach(() => { + dispatchInboundMessageMock.mockClear(); + enqueueSystemEventMock.mockClear(); + }); + + it("drops a well-formed reaction envelope via handleReactionOnlyInbound", async () => { + const deps = createBaseSignalEventHandlerDeps({ + isSignalReactionMessage: (r): r is NonNullable => + Boolean(r?.emoji && r?.targetSentTimestamp && (r?.targetAuthor || r?.targetAuthorUuid)), + }); + const handler = createSignalEventHandler(deps); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + timestamp: 1700000000000, + message: "", + reaction: { + emoji: "👍", + isRemove: false, + targetAuthor: "+15550001111", + targetSentTimestamp: 1699999000000, + }, + }, + }), + ); + + expect(dispatchInboundMessageMock).not.toHaveBeenCalled(); + }); + + it("surfaces a bare reaction (missing targetAuthor) as a system event", async () => { + // isSignalReactionMessage returns false (default harness) → bare reaction guard kicks in + const deps = createBaseSignalEventHandlerDeps({ + reactionMode: "all", + shouldEmitSignalReactionNotification: () => true, + buildSignalReactionSystemEventText: (params) => + `Signal reaction added: ${params.emojiLabel} by ${params.actorLabel} msg ${params.messageId}`, + }); + const handler = createSignalEventHandler(deps); + + await handler( + createSignalReceiveEvent({ + sourceName: "Alice", + dataMessage: { + timestamp: 1700000000000, + message: "", + reaction: { + emoji: "👍", + isRemove: false, + // targetAuthor / targetAuthorUuid deliberately absent + targetSentTimestamp: 1699999000000, + }, + }, + }), + ); + + expect(dispatchInboundMessageMock).not.toHaveBeenCalled(); + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventMock).toHaveBeenCalledWith( + expect.stringContaining("👍"), + expect.objectContaining({ + contextKey: expect.stringContaining("reaction"), + }), + ); + }); + + it("includes targetSentTimestamp in system event text when available", async () => { + const capturedText: string[] = []; + const deps = createBaseSignalEventHandlerDeps({ + reactionMode: "all", + shouldEmitSignalReactionNotification: () => true, + buildSignalReactionSystemEventText: (params) => { + const text = `Signal reaction added: ${params.emojiLabel} by ${params.actorLabel} msg ${params.messageId}`; + capturedText.push(text); + return text; + }, + }); + const handler = createSignalEventHandler(deps); + + await handler( + createSignalReceiveEvent({ + sourceName: "Alice", + dataMessage: { + timestamp: 1700000000000, + message: "", + reaction: { + emoji: "❤️", + isRemove: false, + targetSentTimestamp: 1699999000000, + }, + }, + }), + ); + + expect(capturedText[0]).toContain("1699999000000"); + }); + + it("drops a bare reaction-removal (isRemove: true) silently", async () => { + const deps = createBaseSignalEventHandlerDeps(); + const handler = createSignalEventHandler(deps); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + timestamp: 1700000000000, + message: "", + reaction: { + emoji: "👍", + isRemove: true, + targetSentTimestamp: 1699999000000, + }, + }, + }), + ); + + expect(dispatchInboundMessageMock).not.toHaveBeenCalled(); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("handles a bare reaction that arrives with a null-contentType attachment (signal-cli thumbnail)", async () => { + const deps = createBaseSignalEventHandlerDeps({ + reactionMode: "all", + shouldEmitSignalReactionNotification: () => true, + buildSignalReactionSystemEventText: (params) => + `Signal reaction added: ${params.emojiLabel} by ${params.actorLabel}`, + }); + const handler = createSignalEventHandler(deps); + + await handler( + createSignalReceiveEvent({ + sourceName: "Alice", + dataMessage: { + timestamp: 1700000000000, + message: "", + reaction: { + emoji: "👍", + isRemove: false, + targetSentTimestamp: 1699999000000, + }, + attachments: [{ id: "thumb1", contentType: null, size: 0 }], + }, + }), + ); + + expect(dispatchInboundMessageMock).not.toHaveBeenCalled(); + expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); + expect(enqueueSystemEventMock).toHaveBeenCalledWith( + expect.stringContaining("👍"), + expect.objectContaining({ contextKey: expect.stringContaining("reaction") }), + ); + }); + + it("does NOT intercept a reaction envelope that also has message text", async () => { + const deps = createBaseSignalEventHandlerDeps(); + const handler = createSignalEventHandler(deps); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + timestamp: 1700000000000, + message: "hello", + reaction: { + emoji: "👍", + isRemove: false, + targetSentTimestamp: 1699999000000, + }, + }, + }), + ); + + // Message has body text — should be dispatched normally + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT intercept a plain message without a reaction field", async () => { + const deps = createBaseSignalEventHandlerDeps(); + const handler = createSignalEventHandler(deps); + + await handler( + createSignalReceiveEvent({ + dataMessage: { + timestamp: 1700000000000, + message: "hey there", + }, + }), + ); + + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 499d0d845ff..60d710f1b9b 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -512,6 +512,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { } const dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage; + const reaction = deps.isSignalReactionMessage(envelope.reactionMessage) ? envelope.reactionMessage : deps.isSignalReactionMessage(dataMessage?.reaction) @@ -525,9 +526,17 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const messageText = normalizedMessage.trim(); const quoteText = dataMessage?.quote?.text?.trim() ?? ""; - const hasBodyContent = - Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length); - const senderDisplay = formatSignalSenderDisplay(sender); + + // Guard: if dataMessage carries a reaction field but isSignalReactionMessage() + // returned null (e.g. targetAuthor/targetAuthorUuid absent in some signal-cli versions), + // and there is no real body content, surface it as a system event (matching the + // Discord reaction pattern) rather than leaking through as . + const bareReaction = dataMessage?.reaction; + // Note: some signal-cli builds attach a null-contentType attachment alongside + // the reaction field (e.g. thumbnail of the reacted-to message). We intentionally + // omit the attachments check so those are caught here rather than leaking as + // . + // Resolve full access state early — shared by bare reaction path and normal dispatch. const { resolveAccessDecision, dmAccess, effectiveDmAllow, effectiveGroupAllow } = await resolveSignalAccessState({ accountId: deps.accountId, @@ -538,6 +547,84 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { sender, }); + const hasBareReactionField = !reaction && Boolean(bareReaction) && !messageText && !quoteText; + if (hasBareReactionField && bareReaction) { + const senderDisplayBare = formatSignalSenderDisplay(sender); + const emojiLabel = (bareReaction as { emoji?: string | null }).emoji?.trim() || "emoji"; + const isRemove = Boolean((bareReaction as { isRemove?: boolean | null }).isRemove); + const targetTimestamp = (bareReaction as { targetSentTimestamp?: number | null }) + .targetSentTimestamp; + logVerbose(`signal: bare reaction (${emojiLabel}) from ${senderDisplayBare}`); + if (!isRemove) { + // P2: prefer group info from the reaction payload itself; fall back to dataMessage.groupInfo. + const bareReactionGroupInfo = + (bareReaction as { groupInfo?: { groupId?: string; groupName?: string } | null }) + .groupInfo ?? dataMessage?.groupInfo; + const groupId = bareReactionGroupInfo?.groupId ?? undefined; + const groupName = bareReactionGroupInfo?.groupName ?? undefined; + const isGroup = Boolean(groupId); + // Apply full access policy (dmPolicy/groupPolicy) — same as handleReactionOnlyInbound. + const bareAccessDecision = resolveAccessDecision(isGroup); + if (bareAccessDecision.decision !== "allow") { + logVerbose( + `signal: bare reaction from unauthorized sender ${senderDisplayBare}, dropping (${bareAccessDecision.reasonCode ?? "policy"})`, + ); + return; + } + // Apply notification-mode gating (off/own/all/allowlist). + const bareReactionTargets = deps.resolveSignalReactionTargets(bareReaction); + const shouldNotifyBare = deps.shouldEmitSignalReactionNotification({ + mode: deps.reactionMode, + account: deps.account, + targets: bareReactionTargets, + sender, + allowlist: deps.reactionAllowlist, + }); + if (!shouldNotifyBare) { + logVerbose(`signal: bare reaction suppressed (reactionMode=${deps.reactionMode})`); + return; + } + const senderName = envelope.sourceName ?? senderDisplayBare; + const senderPeerIdBare = resolveSignalPeerId(sender); + const routeBare = resolveAgentRoute({ + cfg: deps.cfg, + channel: "signal", + accountId: deps.accountId, + peer: { + kind: isGroup ? "group" : "direct", + id: isGroup ? (groupId ?? "unknown") : senderPeerIdBare, + }, + }); + const messageId = typeof targetTimestamp === "number" ? String(targetTimestamp) : "unknown"; + const groupLabel = isGroup ? `${groupName ?? "Signal Group"} id:${groupId}` : undefined; + const text = deps.buildSignalReactionSystemEventText({ + emojiLabel, + actorLabel: senderName, + messageId, + groupLabel, + }); + enqueueSystemEvent(text, { + sessionKey: routeBare.sessionKey, + contextKey: [ + "signal", + "reaction", + "added", + messageId, + senderPeerIdBare, + emojiLabel, + groupId ?? "", + ] + .filter(Boolean) + .join(":"), + }); + } + return; + } + + const hasBodyContent = + Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length); + const senderDisplay = formatSignalSenderDisplay(sender); + if ( reaction && handleReactionOnlyInbound({ @@ -774,10 +861,14 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { if (mediaPaths.length > 1) { placeholder = formatAttachmentSummaryPlaceholder(mediaTypes); } else { - const kind = kindFromMime(mediaType ?? undefined); - if (kind) { + // Only set placeholder when we actually resolved a mediaType. + // kindFromMime(undefined) returns "unknown" which is truthy — guard against + // that case so null-body messages (e.g. blank reaction envelopes) aren't dispatched + // with as the body. + const kind = mediaType ? kindFromMime(mediaType) : undefined; + if (kind && kind !== "unknown") { placeholder = ``; - } else if (attachments.length) { + } else if (kind === "unknown" || (!kind && dataMessage.attachments?.length)) { placeholder = ""; } } From f14ea9f77f6336684ee16ad7c56588787f7d5c95 Mon Sep 17 00:00:00 2001 From: Dale Date: Sun, 8 Mar 2026 04:06:33 -0400 Subject: [PATCH 09/12] fix(signal): remove stale 'unknown' MediaKind comparisons kindFromMime() returns MediaKind | undefined, never the string 'unknown'. The old guard against was based on prior behavior that no longer exists. Simplify to: use when kind is defined, fall back to when attachments are present but mime is unresolvable, and emit nothing for blank envelopes. --- extensions/signal/src/monitor/event-handler.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 60d710f1b9b..3db0acf8078 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -862,13 +862,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { placeholder = formatAttachmentSummaryPlaceholder(mediaTypes); } else { // Only set placeholder when we actually resolved a mediaType. - // kindFromMime(undefined) returns "unknown" which is truthy — guard against - // that case so null-body messages (e.g. blank reaction envelopes) aren't dispatched - // with as the body. + // Guard against undefined kind (e.g. blank reaction envelopes) so null-body + // messages aren't dispatched with a spurious body. const kind = mediaType ? kindFromMime(mediaType) : undefined; - if (kind && kind !== "unknown") { + if (kind) { placeholder = ``; - } else if (kind === "unknown" || (!kind && dataMessage.attachments?.length)) { + } else if (dataMessage.attachments?.length) { placeholder = ""; } } From db178890fe560a872f80be8a722418fbb813febc Mon Sep 17 00:00:00 2001 From: Dale Babiy Date: Thu, 12 Mar 2026 04:24:01 +0000 Subject: [PATCH 10/12] test(signal): add group-mentions ack test for mentioned case --- .../event-handler.ack-reaction.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts index ef6f20d9e21..a8fc994df2c 100644 --- a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts +++ b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts @@ -153,6 +153,34 @@ describe("Signal ACK reactions", () => { expect(sendReactionSignal).not.toHaveBeenCalled(); }); + it("sends ack for group when scope=group-mentions and agent is mentioned", async () => { + const deps = makeDeps({ + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + groupChat: { mentionPatterns: ["\\bpinky\\b"] }, + }, + }); + const handler = createSignalEventHandler(deps); + await handler( + makeEvent({ + dataMessage: { + message: "hey pinky what do you think?", + timestamp: 1700000000000, + groupInfo: { groupId: "grp123", groupName: "Test Group" }, + }, + }), + ); + + // group-mentions with a configured pattern that matches the message → should ACK + expect(sendReactionSignal).toHaveBeenCalledWith( + "+15550001111", + 1700000000000, + "👀", + expect.objectContaining({ groupId: "grp123" }), + ); + }); + it("does NOT send ack when reactionLevel=minimal", async () => { const deps = makeDeps({ accountOverrides: { reactionLevel: "minimal" }, From 29c3eebc84418621ad78431f342b9697ba699db1 Mon Sep 17 00:00:00 2001 From: minupla Date: Sat, 14 Mar 2026 14:01:51 +0000 Subject: [PATCH 11/12] fix(signal): correct import paths in ack-reaction.ts Imports were using ../../agents, ../../channels etc. which resolve to nonexistent paths under extensions/signal/. The correct pattern for files in extensions/signal/src/monitor/ is ../../../../src/, matching the convention used in event-handler.ts and other monitor files. --- extensions/signal/src/monitor/ack-reaction.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/signal/src/monitor/ack-reaction.ts b/extensions/signal/src/monitor/ack-reaction.ts index 7e8785abfff..b00656cef02 100644 --- a/extensions/signal/src/monitor/ack-reaction.ts +++ b/extensions/signal/src/monitor/ack-reaction.ts @@ -3,10 +3,10 @@ * Call this BEFORE inboundDebouncer.enqueue() in event-handler.ts. */ -import { resolveAckReaction } from "../../agents/identity.js"; -import { shouldAckReaction } from "../../channels/ack-reactions.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { logVerbose } from "../../globals.js"; +import { resolveAckReaction } from "../../../../src/agents/identity.js"; +import { shouldAckReaction } from "../../../../src/channels/ack-reactions.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { logVerbose } from "../../../../src/globals.js"; import { resolveSignalReactionLevel } from "../reaction-level.js"; import { sendReactionSignal } from "../send-reactions.js"; From 82f27203605b018741a30b598e2d8610c773ad52 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Mar 2026 16:14:09 +0000 Subject: [PATCH 12/12] fix(signal): correct mock import paths in monitor test files Use ../../../../src/ relative paths from extensions/signal/src/monitor/ instead of ../../ which resolved to the non-existent extensions/signal/src/auto-reply/. --- .../signal/src/monitor/event-handler.ack-reaction.test.ts | 4 ++-- .../src/monitor/event-handler.emoji-reactions.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts index a8fc994df2c..f45fc719a14 100644 --- a/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts +++ b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts @@ -4,11 +4,11 @@ vi.mock("../send-reactions.js", () => ({ sendReactionSignal: vi.fn().mockResolvedValue({ ok: true }), })); -vi.mock("../../auto-reply/dispatch.js", () => ({ +vi.mock("../../../../src/auto-reply/dispatch.js", () => ({ dispatchInboundMessage: vi.fn().mockResolvedValue({ queuedFinal: false }), })); -import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; +import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js"; import { sendReactionSignal } from "../send-reactions.js"; import { createSignalEventHandler } from "./event-handler.js"; import { diff --git a/extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts b/extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts index 8ece0f6a672..944f91c6764 100644 --- a/extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts +++ b/extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts @@ -35,8 +35,8 @@ vi.mock("../send.js", () => ({ sendReadReceiptSignal: vi.fn().mockResolvedValue(true), })); -vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, dispatchInboundMessage: dispatchInboundMessageMock, @@ -45,7 +45,7 @@ vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => { }; }); -vi.mock("../../infra/system-events.js", () => ({ +vi.mock("../../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: enqueueSystemEventMock, }));