Merge 82f27203605b018741a30b598e2d8610c773ad52 into d78e13f545136fcbba1feceecc5e0485a06c33a6
This commit is contained in:
commit
2e8b7dd589
72
extensions/signal/src/monitor/ack-reaction.ts
Normal file
72
extensions/signal/src/monitor/ack-reaction.ts
Normal 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)}`);
|
||||
});
|
||||
}
|
||||
286
extensions/signal/src/monitor/event-handler.ack-reaction.test.ts
Normal file
286
extensions/signal/src/monitor/event-handler.ack-reaction.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>";
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user