diff --git a/extensions/signal/src/monitor/ack-reaction.ts b/extensions/signal/src/monitor/ack-reaction.ts new file mode 100644 index 00000000000..b00656cef02 --- /dev/null +++ b/extensions/signal/src/monitor/ack-reaction.ts @@ -0,0 +1,72 @@ +/** + * Sends an instant ACK reaction on inbound Signal messages when reactionLevel === "ack". + * Call this BEFORE inboundDebouncer.enqueue() in event-handler.ts. + */ + +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"; + +export function maybeSendSignalAckReaction(params: { + cfg: OpenClawConfig; + agentId: string; + senderRecipient: string; + 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; +}): void { + const { ackEnabled } = resolveSignalReactionLevel({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!ackEnabled) { + return; + } + + const emoji = resolveAckReaction(params.cfg, params.agentId, { + channel: "signal", + accountId: params.accountId, + }).trim(); + if (!emoji) { + return; + } + + 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: params.isGroup && canDetectMention, + requireMention, + canDetectMention, + effectiveWasMentioned: wasMentioned, + shouldBypassMention: !requireMention, + }); + 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..f45fc719a14 --- /dev/null +++ b/extensions/signal/src/monitor/event-handler.ack-reaction.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../send-reactions.js", () => ({ + sendReactionSignal: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock("../../../../src/auto-reply/dispatch.js", () => ({ + dispatchInboundMessage: vi.fn().mockResolvedValue({ queuedFinal: false }), +})); + +import { dispatchInboundMessage } from "../../../../src/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 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("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" }, + }); + 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("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: 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 }, + }), + ); + + 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 () => { + 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.emoji-reactions.test.ts b/extensions/signal/src/monitor/event-handler.emoji-reactions.test.ts new file mode 100644 index 00000000000..944f91c6764 --- /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("../../../../src/auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchInboundMessage: dispatchInboundMessageMock, + dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock, + dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock, + }; +}); + +vi.mock("../../../../src/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 58ff8d4f8d7..73250254750 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -50,6 +50,7 @@ import { import { normalizeSignalMessagingTarget } from "../runtime-api.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, @@ -508,6 +509,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) @@ -521,9 +523,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, @@ -534,6 +544,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({ @@ -698,6 +786,39 @@ 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(), + ); + + // 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 && ackTimestamp !== undefined) { + maybeSendSignalAckReaction({ + cfg: deps.cfg, + agentId: route.agentId, + senderRecipient, + targetTimestamp: ackTimestamp, + 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[] = []; @@ -737,10 +858,13 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { if (mediaPaths.length > 1) { placeholder = formatAttachmentSummaryPlaceholder(mediaTypes); } else { - const kind = kindFromMime(mediaType ?? undefined); + // Only set placeholder when we actually resolved a mediaType. + // 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) { placeholder = ``; - } else if (attachments.length) { + } else if (dataMessage.attachments?.length) { placeholder = ""; } }