Merge 82f27203605b018741a30b598e2d8610c773ad52 into d78e13f545136fcbba1feceecc5e0485a06c33a6

This commit is contained in:
Dale Babiy 2026-03-21 05:00:01 +00:00 committed by GitHub
commit 2e8b7dd589
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 734 additions and 5 deletions

View File

@ -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)}`);
});
}

View File

@ -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<string, unknown> = {}) {
const accountOverrides = (cfgOverrides.accountOverrides as Record<string, unknown>) ?? undefined;
const messagesOverrides = (cfgOverrides.messages as Record<string, unknown>) ?? undefined;
return createBaseSignalEventHandlerDeps({
cfg: {
channels: {
signal: {
accounts: {
default: {
reactionLevel: "ack",
...accountOverrides,
},
},
},
},
messages: {
ackReaction: "👀",
ackReactionScope: "all",
...messagesOverrides,
},
...cfgOverrides,
},
ignoreAttachments: true,
});
}
function makeEvent(overrides: Record<string, unknown> = {}) {
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<typeof dispatchInboundMessage> 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");
});
});

View File

@ -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 <media:unknown>.
*
* 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<typeof import("../../../../src/auto-reply/dispatch.js")>();
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<typeof r> =>
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);
});
});

View File

@ -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 <media:unknown>.
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
// <media:unknown>.
// 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 <media:…> body.
const kind = mediaType ? kindFromMime(mediaType) : undefined;
if (kind) {
placeholder = `<media:${kind}>`;
} else if (attachments.length) {
} else if (dataMessage.attachments?.length) {
placeholder = "<media:attachment>";
}
}