feat(signal): add reaction notifications

This commit is contained in:
Peter Steinberger
2026-01-09 23:53:28 +01:00
parent 90d6a55e05
commit 89dc6ebb8b
7 changed files with 266 additions and 17 deletions

View File

@@ -105,6 +105,7 @@
- Messages: default inbound/outbound prefixes from the routed agents `identity.name` when set. (#578) — thanks @p6l-richard
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
- Signal: ignore reaction-only messages so they don't surface as unknown media. (#616) — thanks @neist
- Signal: add reaction notifications with allowlist support. — thanks @steipete
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Onboarding: tighten QuickStart hint copy for configuring later.
- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik

View File

@@ -846,6 +846,26 @@ Slack action groups (gate `slack` tool actions):
| pins | enabled | Pin/unpin/list |
| memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list |
### `signal` (signal-cli)
Signal reactions can emit system events (shared reaction tooling):
```json5
{
signal: {
reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"]
}
}
```
Reaction notification modes:
- `off`: no reaction events.
- `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages.
- `allowlist`: reactions from `signal.reactionAllowlist` on all messages (empty list disables).
### `imessage` (imsg CLI)
Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.

View File

@@ -16,3 +16,4 @@ Provider notes:
- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji.
- **Telegram**: `remove: true` removes your own reaction (Bot API limitation).
- **WhatsApp**: `remove: true` maps to empty emoji (remove bot reaction).
- **Signal**: inbound reaction notifications emit system events when `signal.reactionNotifications` is enabled.

View File

@@ -520,6 +520,12 @@ export type SlackChannelConfig = {
systemPrompt?: string;
};
export type SignalReactionNotificationMode =
| "off"
| "own"
| "all"
| "allowlist";
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SlackActionConfig = {
@@ -625,6 +631,10 @@ export type SignalAccountConfig = {
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
mediaMaxMb?: number;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: SignalReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>;
};
export type SignalConfig = {

View File

@@ -413,6 +413,8 @@ const SignalAccountSchemaBase = z.object({
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().int().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
});
const SignalAccountSchema = SignalAccountSchemaBase.superRefine(

View File

@@ -1,5 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
peekSystemEvents,
resetSystemEventsForTest,
} from "../infra/system-events.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { normalizeE164 } from "../utils.js";
import { monitorSignalProvider } from "./monitor.js";
const sendMock = vi.fn();
@@ -68,6 +75,7 @@ beforeEach(() => {
upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
resetSystemEventsForTest();
});
describe("monitorSignalProvider tool results", () => {
@@ -189,6 +197,97 @@ describe("monitorSignalProvider tool results", () => {
expect(updateLastRouteMock).not.toHaveBeenCalled();
});
it("enqueues system events for reaction notifications", async () => {
config = {
...config,
signal: {
autoStart: false,
dmPolicy: "open",
allowFrom: ["*"],
reactionNotifications: "all",
},
};
const abortController = new AbortController();
streamMock.mockImplementation(async ({ onEvent }) => {
const payload = {
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1,
reactionMessage: {
emoji: "✅",
targetAuthor: "+15550002222",
targetSentTimestamp: 2,
},
},
};
await onEvent({
event: "receive",
data: JSON.stringify(payload),
});
abortController.abort();
});
await monitorSignalProvider({
autoStart: false,
baseUrl: "http://127.0.0.1:8080",
abortSignal: abortController.signal,
});
await flush();
const route = resolveAgentRoute({
cfg: config as ClawdbotConfig,
provider: "signal",
accountId: "default",
peer: { kind: "dm", id: normalizeE164("+15550001111") },
});
const events = peekSystemEvents(route.sessionKey);
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(
true,
);
});
it("processes messages when reaction metadata is present", async () => {
const abortController = new AbortController();
replyMock.mockResolvedValue({ text: "pong" });
streamMock.mockImplementation(async ({ onEvent }) => {
const payload = {
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1,
reactionMessage: {
emoji: "👍",
targetAuthor: "+15550002222",
targetSentTimestamp: 2,
},
dataMessage: {
message: "ping",
},
},
};
await onEvent({
event: "receive",
data: JSON.stringify(payload),
});
abortController.abort();
});
await monitorSignalProvider({
autoStart: false,
baseUrl: "http://127.0.0.1:8080",
abortSignal: abortController.signal,
});
await flush();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(updateLastRouteMock).toHaveBeenCalled();
});
it("does not resend pairing code when a request is already pending", async () => {
config = {
...config,

View File

@@ -7,7 +7,9 @@ import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import type { SignalReactionNotificationMode } from "../config/types.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
@@ -50,6 +52,10 @@ type SignalReactionMessage = {
targetAuthorUuid?: string | null;
targetSentTimestamp?: number | null;
isRemove?: boolean | null;
groupInfo?: {
groupId?: string | null;
groupName?: string | null;
} | null;
};
type SignalDataMessage = {
@@ -112,6 +118,66 @@ function normalizeAllowList(raw?: Array<string | number>): string[] {
return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
type SignalReactionTarget = {
kind: "phone" | "uuid";
id: string;
display: string;
};
function resolveSignalReactionTarget(
reaction: SignalReactionMessage,
): SignalReactionTarget | null {
const uuid = reaction.targetAuthorUuid?.trim();
if (uuid) {
return { kind: "uuid", id: uuid, display: `uuid:${uuid}` };
}
const author = reaction.targetAuthor?.trim();
if (!author) return null;
const normalized = normalizeE164(author);
return { kind: "phone", id: normalized, display: normalized };
}
function shouldEmitSignalReactionNotification(params: {
mode?: SignalReactionNotificationMode;
account?: string | null;
target?: SignalReactionTarget | null;
sender?: ReturnType<typeof resolveSignalSender> | null;
allowlist?: string[];
}) {
const { mode, account, target, sender, allowlist } = params;
const effectiveMode = mode ?? "own";
if (effectiveMode === "off") return false;
if (effectiveMode === "own") {
const accountId = account?.trim();
if (!accountId || !target) return false;
if (target.kind === "uuid") {
return accountId === target.id || accountId === `uuid:${target.id}`;
}
return normalizeE164(accountId) === target.id;
}
if (effectiveMode === "allowlist") {
if (!sender || !allowlist || allowlist.length === 0) return false;
return isSignalSenderAllowed(sender, allowlist);
}
return true;
}
function buildSignalReactionSystemEventText(params: {
emojiLabel: string;
actorLabel: string;
messageId: string;
targetLabel?: string;
groupLabel?: string;
}) {
const base = `Signal reaction added: ${params.emojiLabel} by ${params.actorLabel} msg ${params.messageId}`;
const withTarget = params.targetLabel
? `${base} from ${params.targetLabel}`
: base;
return params.groupLabel
? `${withTarget} in ${params.groupLabel}`
: withTarget;
}
async function waitForSignalDaemonReady(params: {
baseUrl: string;
abortSignal?: AbortSignal;
@@ -253,6 +319,10 @@ export async function monitorSignalProvider(
: []),
);
const groupPolicy = accountInfo.config.groupPolicy ?? "open";
const reactionMode = accountInfo.config.reactionNotifications ?? "own";
const reactionAllowlist = normalizeAllowList(
accountInfo.config.reactionAllowlist,
);
const mediaMaxBytes =
(opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
const ignoreAttachments =
@@ -315,23 +385,6 @@ export async function monitorSignalProvider(
if (!envelope) return;
if (envelope.syncMessage) return;
const dataMessage =
envelope.dataMessage ?? envelope.editMessage?.dataMessage;
if (envelope.reactionMessage && !dataMessage) {
const reaction = envelope.reactionMessage;
if (reaction.isRemove) return; // Ignore reaction removals
const emoji = reaction.emoji ?? "unknown";
const sender = resolveSignalSender(envelope);
if (!sender) return;
const senderDisplay = formatSignalSenderDisplay(sender);
const senderName = envelope.sourceName ?? senderDisplay;
logVerbose(`signal reaction: ${emoji} from ${senderName}`);
// Skip processing reactions as messages for now - just log them
// Future: could dispatch as a notification or store for context
return;
}
if (!dataMessage) return;
const sender = resolveSignalSender(envelope);
if (!sender) return;
if (account && sender.kind === "phone") {
@@ -339,6 +392,69 @@ export async function monitorSignalProvider(
return;
}
}
const dataMessage =
envelope.dataMessage ?? envelope.editMessage?.dataMessage;
if (envelope.reactionMessage && !dataMessage) {
const reaction = envelope.reactionMessage;
if (reaction.isRemove) return; // Ignore reaction removals
const emojiLabel = reaction.emoji?.trim() || "emoji";
const senderDisplay = formatSignalSenderDisplay(sender);
const senderName = envelope.sourceName ?? senderDisplay;
logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`);
const target = resolveSignalReactionTarget(reaction);
const shouldNotify = shouldEmitSignalReactionNotification({
mode: reactionMode,
account,
target,
sender,
allowlist: reactionAllowlist,
});
if (!shouldNotify) return;
const groupId = reaction.groupInfo?.groupId ?? undefined;
const groupName = reaction.groupInfo?.groupName ?? undefined;
const isGroup = Boolean(groupId);
const senderPeerId = resolveSignalPeerId(sender);
const route = resolveAgentRoute({
cfg,
provider: "signal",
accountId: accountInfo.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup ? (groupId ?? "unknown") : senderPeerId,
},
});
const groupLabel = isGroup
? `${groupName ?? "Signal Group"} id:${groupId}`
: undefined;
const messageId = reaction.targetSentTimestamp
? String(reaction.targetSentTimestamp)
: "unknown";
const text = buildSignalReactionSystemEventText({
emojiLabel,
actorLabel: senderName,
messageId,
targetLabel: target?.display,
groupLabel,
});
const senderId = formatSignalSenderId(sender);
const contextKey = [
"signal",
"reaction",
"added",
messageId,
senderId,
emojiLabel,
groupId ?? "",
]
.filter(Boolean)
.join(":");
enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey,
});
return;
}
if (!dataMessage) return;
const senderDisplay = formatSignalSenderDisplay(sender);
const senderRecipient = resolveSignalRecipient(sender);
const senderPeerId = resolveSignalPeerId(sender);