feat(signal): add reaction notifications
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user