From 89dc6ebb8bafbfc627b9063212c6280ba24383e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 23:53:28 +0100 Subject: [PATCH] feat(signal): add reaction notifications --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 20 ++++ docs/tools/reactions.md | 1 + src/config/types.ts | 10 ++ src/config/zod-schema.ts | 2 + src/signal/monitor.tool-result.test.ts | 99 ++++++++++++++++ src/signal/monitor.ts | 150 ++++++++++++++++++++++--- 7 files changed, 266 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d71b7728f..4f9ef3660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ - Messages: default inbound/outbound prefixes from the routed agent’s `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 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 282d3adfd..799197029 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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. diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 21fe37e0a..151f6805e 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -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. diff --git a/src/config/types.ts b/src/config/types.ts index 0791c91ce..443d5bae7 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; }; export type SignalConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c8bd5f19e..56266928e 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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( diff --git a/src/signal/monitor.tool-result.test.ts b/src/signal/monitor.tool-result.test.ts index 2b760fee1..0ba6eb130 100644 --- a/src/signal/monitor.tool-result.test.ts +++ b/src/signal/monitor.tool-result.test.ts @@ -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, diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index b240aeb1b..a5fab8022 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -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[] { 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 | 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);