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
|
- 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: 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: 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.
|
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
|
||||||
- Onboarding: tighten QuickStart hint copy for configuring later.
|
- 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
|
- 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 |
|
| pins | enabled | Pin/unpin/list |
|
||||||
| memberInfo | enabled | Member info |
|
| memberInfo | enabled | Member info |
|
||||||
| emojiList | enabled | Custom emoji list |
|
| 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)
|
### `imessage` (imsg CLI)
|
||||||
|
|
||||||
Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
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.
|
- **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).
|
- **Telegram**: `remove: true` removes your own reaction (Bot API limitation).
|
||||||
- **WhatsApp**: `remove: true` maps to empty emoji (remove bot reaction).
|
- **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;
|
systemPrompt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SignalReactionNotificationMode =
|
||||||
|
| "off"
|
||||||
|
| "own"
|
||||||
|
| "all"
|
||||||
|
| "allowlist";
|
||||||
|
|
||||||
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||||
|
|
||||||
export type SlackActionConfig = {
|
export type SlackActionConfig = {
|
||||||
@@ -625,6 +631,10 @@ export type SignalAccountConfig = {
|
|||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
mediaMaxMb?: number;
|
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 = {
|
export type SignalConfig = {
|
||||||
|
|||||||
@@ -413,6 +413,8 @@ const SignalAccountSchemaBase = z.object({
|
|||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().int().positive().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(
|
const SignalAccountSchema = SignalAccountSchemaBase.superRefine(
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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";
|
import { monitorSignalProvider } from "./monitor.js";
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
const sendMock = vi.fn();
|
||||||
@@ -68,6 +75,7 @@ beforeEach(() => {
|
|||||||
upsertPairingRequestMock
|
upsertPairingRequestMock
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
|
resetSystemEventsForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("monitorSignalProvider tool results", () => {
|
describe("monitorSignalProvider tool results", () => {
|
||||||
@@ -189,6 +197,97 @@ describe("monitorSignalProvider tool results", () => {
|
|||||||
expect(updateLastRouteMock).not.toHaveBeenCalled();
|
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 () => {
|
it("does not resend pairing code when a request is already pending", async () => {
|
||||||
config = {
|
config = {
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { ReplyPayload } from "../auto-reply/types.js";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||||
|
import type { SignalReactionNotificationMode } from "../config/types.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||||
@@ -50,6 +52,10 @@ type SignalReactionMessage = {
|
|||||||
targetAuthorUuid?: string | null;
|
targetAuthorUuid?: string | null;
|
||||||
targetSentTimestamp?: number | null;
|
targetSentTimestamp?: number | null;
|
||||||
isRemove?: boolean | null;
|
isRemove?: boolean | null;
|
||||||
|
groupInfo?: {
|
||||||
|
groupId?: string | null;
|
||||||
|
groupName?: string | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SignalDataMessage = {
|
type SignalDataMessage = {
|
||||||
@@ -112,6 +118,66 @@ function normalizeAllowList(raw?: Array<string | number>): string[] {
|
|||||||
return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
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: {
|
async function waitForSignalDaemonReady(params: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
@@ -253,6 +319,10 @@ export async function monitorSignalProvider(
|
|||||||
: []),
|
: []),
|
||||||
);
|
);
|
||||||
const groupPolicy = accountInfo.config.groupPolicy ?? "open";
|
const groupPolicy = accountInfo.config.groupPolicy ?? "open";
|
||||||
|
const reactionMode = accountInfo.config.reactionNotifications ?? "own";
|
||||||
|
const reactionAllowlist = normalizeAllowList(
|
||||||
|
accountInfo.config.reactionAllowlist,
|
||||||
|
);
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
(opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const ignoreAttachments =
|
const ignoreAttachments =
|
||||||
@@ -315,23 +385,6 @@ export async function monitorSignalProvider(
|
|||||||
if (!envelope) return;
|
if (!envelope) return;
|
||||||
if (envelope.syncMessage) 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);
|
const sender = resolveSignalSender(envelope);
|
||||||
if (!sender) return;
|
if (!sender) return;
|
||||||
if (account && sender.kind === "phone") {
|
if (account && sender.kind === "phone") {
|
||||||
@@ -339,6 +392,69 @@ export async function monitorSignalProvider(
|
|||||||
return;
|
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 senderDisplay = formatSignalSenderDisplay(sender);
|
||||||
const senderRecipient = resolveSignalRecipient(sender);
|
const senderRecipient = resolveSignalRecipient(sender);
|
||||||
const senderPeerId = resolveSignalPeerId(sender);
|
const senderPeerId = resolveSignalPeerId(sender);
|
||||||
|
|||||||
Reference in New Issue
Block a user