fix: signal reactions

This commit is contained in:
Peter Steinberger
2026-01-25 03:20:09 +00:00
parent 116fbb747f
commit 3a35d313d9
21 changed files with 808 additions and 10 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.

View File

@@ -105,8 +105,29 @@ Groups:
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
- Signal-cli does not expose read receipts for groups.
## Reactions (message tool)
- Use `message action=react` with `channel=signal`.
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
- `messageId` is the Signal timestamp for the message youre reacting to.
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
Examples:
```
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
```
Config:
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
- `off`/`ack` disables agent reactions (message tool `react` will error).
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
## Delivery targets (CLI/cron)
- DMs: `signal:+15551234567` (or plain E.164).
- UUID DMs: `uuid:<id>` (or bare UUID).
- Groups: `signal:group:<groupId>`.
- Usernames: `username:<name>` (if supported by your Signal account).

View File

@@ -66,11 +66,12 @@ Name lookup:
- Discord only: `--poll-duration-hours`, `--message`
- `react`
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
- Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me`
- Signal group reactions: `--target-author` or `--target-author-uuid` required
- `reactions`
- Channels: Discord/Google Chat/Slack
@@ -213,6 +214,13 @@ clawdbot message react --channel slack \
--target C123 --message-id 456 --emoji "✅"
```
React in a Signal group:
```
clawdbot message react --channel signal \
--target signal:group:abc123 --message-id 1737630212345 \
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
```
Send Telegram inline buttons:
```
clawdbot message send --channel telegram --target @mychat --message "Choose:" \

View File

@@ -18,12 +18,20 @@ import {
setAccountEnabledInConfigSection,
signalOnboardingAdapter,
SignalConfigSchema,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ResolvedSignalAccount,
} from "clawdbot/plugin-sdk";
import { getSignalRuntime } from "./runtime.js";
const signalMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx),
supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx),
handleAction: async (ctx) =>
await getSignalRuntime().channel.signal.messageActions.handleAction(ctx),
};
const meta = getChatChannelMeta("signal");
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
@@ -42,7 +50,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
},
actions: signalMessageActions,
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
@@ -115,7 +125,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
normalizeTarget: normalizeSignalMessagingTarget,
targetResolver: {
looksLikeId: looksLikeSignalTargetId,
hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
},
},
setup: {

View File

@@ -15,6 +15,8 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
@@ -255,6 +257,28 @@ export async function compactEmbeddedPiSessionDirect(
}
}
}
const reactionGuidance =
runtimeChannel && params.config
? (() => {
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
: undefined;
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
@@ -313,6 +337,7 @@ export async function compactEmbeddedPiSessionDirect(
ttsHint,
promptMode,
runtimeInfo,
reactionGuidance,
messageToolHints,
sandboxInfo,
tools,

View File

@@ -15,6 +15,7 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
@@ -255,14 +256,25 @@ export async function runEmbeddedAttempt(
}
}
const reactionGuidance =
runtimeChannel === "telegram" && params.config
runtimeChannel && params.config
? (() => {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
: undefined;
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({

View File

@@ -94,6 +94,9 @@ function buildReactionSchema() {
messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
targetAuthor: Type.Optional(Type.String()),
targetAuthorUuid: Type.Optional(Type.String()),
groupId: Type.Optional(Type.String()),
};
}

View File

@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { signalMessageActions } from "./signal.js";
const sendReactionSignal = vi.fn(async () => ({ ok: true }));
const removeReactionSignal = vi.fn(async () => ({ ok: true }));
vi.mock("../../../signal/send-reactions.js", () => ({
sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args),
removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args),
}));
describe("signalMessageActions", () => {
it("returns no actions when no configured accounts exist", () => {
const cfg = {} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual([]);
});
it("hides react when reactions are disabled", () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]);
});
it("enables react when at least one account allows reactions", () => {
const cfg = {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]);
});
it("skips send for plugin dispatch", () => {
expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false);
expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true);
});
it("blocks reactions when action gate is disabled", async () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as ClawdbotConfig;
await expect(
signalMessageActions.handleAction({
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "✅" },
cfg,
accountId: undefined,
}),
).rejects.toThrow(/actions\.reactions/);
});
it("uses account-level actions when enabled", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
cfg,
accountId: "work",
});
expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", {
accountId: "work",
});
});
it("normalizes uuid recipients", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: {
recipient: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "🔥",
},
cfg,
accountId: undefined,
});
expect(sendReactionSignal).toHaveBeenCalledWith(
"123e4567-e89b-12d3-a456-426614174000",
123,
"🔥",
{ accountId: undefined },
);
});
it("requires targetAuthor for group reactions", async () => {
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await expect(
signalMessageActions.handleAction({
action: "react",
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
cfg,
accountId: undefined,
}),
).rejects.toThrow(/targetAuthor/);
});
it("passes groupId and targetAuthor for group reactions", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: {
to: "signal:group:group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "✅",
},
cfg,
accountId: undefined,
});
expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", {
accountId: undefined,
groupId: "group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
targetAuthorUuid: undefined,
});
});
});

View File

@@ -0,0 +1,130 @@
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
import { listEnabledSignalAccounts, resolveSignalAccount } from "../../../signal/accounts.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { sendReactionSignal, removeReactionSignal } from "../../../signal/send-reactions.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
const providerId = "signal";
const GROUP_PREFIX = "group:";
function normalizeSignalReactionRecipient(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return trimmed;
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) return withoutSignal;
if (withoutSignal.toLowerCase().startsWith("uuid:")) {
return withoutSignal.slice("uuid:".length).trim();
}
return withoutSignal;
}
function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId?: string } {
const trimmed = raw.trim();
if (!trimmed) return {};
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) return {};
if (withoutSignal.toLowerCase().startsWith(GROUP_PREFIX)) {
const groupId = withoutSignal.slice(GROUP_PREFIX.length).trim();
return groupId ? { groupId } : {};
}
return { recipient: normalizeSignalReactionRecipient(withoutSignal) };
}
export const signalMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledSignalAccounts(cfg);
if (accounts.length === 0) return [];
const configuredAccounts = accounts.filter((account) => account.configured);
if (configuredAccounts.length === 0) return [];
const actions = new Set<ChannelMessageActionName>(["send"]);
const reactionsEnabled = configuredAccounts.some((account) =>
createActionGate(account.config.actions)("reactions"),
);
if (reactionsEnabled) {
actions.add("react");
}
return Array.from(actions);
},
supportsAction: ({ action }) => action !== "send",
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") {
throw new Error("Send should be handled by outbound, not actions handler.");
}
if (action === "react") {
// Check reaction level first
const reactionLevelInfo = resolveSignalReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`Signal agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.signal.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
// Also check the action gate for backward compatibility
const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions;
const isActionEnabled = createActionGate(actionConfig);
if (!isActionEnabled("reactions")) {
throw new Error("Signal reactions are disabled via actions.reactions.");
}
const recipientRaw =
readStringParam(params, "recipient") ??
readStringParam(params, "to", {
required: true,
label: "recipient (UUID, phone number, or group)",
});
const target = resolveSignalReactionTarget(recipientRaw);
if (!target.recipient && !target.groupId) {
throw new Error("recipient or group required");
}
const messageId = readStringParam(params, "messageId", {
required: true,
label: "messageId (timestamp)",
});
const targetAuthor = readStringParam(params, "targetAuthor");
const targetAuthorUuid = readStringParam(params, "targetAuthorUuid");
if (target.groupId && !targetAuthor && !targetAuthorUuid) {
throw new Error("targetAuthor or targetAuthorUuid required for group reactions.");
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
const timestamp = parseInt(messageId, 10);
if (!Number.isFinite(timestamp)) {
throw new Error(`Invalid messageId: ${messageId}. Expected numeric timestamp.`);
}
if (remove) {
if (!emoji) throw new Error("Emoji required to remove reaction.");
await removeReactionSignal(target.recipient ?? "", timestamp, emoji, {
accountId: accountId ?? undefined,
groupId: target.groupId,
targetAuthor,
targetAuthorUuid,
});
return jsonResult({ ok: true, removed: emoji });
}
if (!emoji) throw new Error("Emoji required to add reaction.");
await sendReactionSignal(target.recipient ?? "", timestamp, emoji, {
accountId: accountId ?? undefined,
groupId: target.groupId,
targetAuthor,
targetAuthorUuid,
});
return jsonResult({ ok: true, added: emoji });
}
throw new Error(`Action ${action} not supported for ${providerId}.`);
},
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js";
describe("signal target normalization", () => {
it("normalizes uuid targets by stripping uuid:", () => {
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("normalizes signal:uuid targets", () => {
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("accepts uuid prefixes for target detection", () => {
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
});
it("accepts compact UUIDs for target detection", () => {
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
});
it("rejects invalid uuid prefixes", () => {
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
});
});

View File

@@ -19,12 +19,30 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined
const id = normalized.slice("u:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("uuid:")) {
const id = normalized.slice("uuid:".length).trim();
return id ? id.toLowerCase() : undefined;
}
return normalized.toLowerCase();
}
// UUID pattern for signal-cli recipient IDs
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i;
export function looksLikeSignalTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
if (/^(signal:)?uuid:/i.test(trimmed)) {
const stripped = trimmed
.replace(/^signal:/i, "")
.replace(/^uuid:/i, "")
.trim();
if (!stripped) return false;
return UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped);
}
// Accept UUIDs (used by signal-cli for reactions)
if (UUID_PATTERN.test(trimmed) || UUID_COMPACT_PATTERN.test(trimmed)) return true;
return /^\+?\d{3,}$/.test(trimmed);
}

View File

@@ -67,6 +67,28 @@ describe("cli program (smoke)", () => {
expect(messageCommand).toHaveBeenCalled();
});
it("runs message react with signal author fields", async () => {
const program = buildProgram();
await program.parseAsync(
[
"message",
"react",
"--channel",
"signal",
"--target",
"signal:group:abc123",
"--message-id",
"1737630212345",
"--emoji",
"✅",
"--target-author-uuid",
"123e4567-e89b-12d3-a456-426614174000",
],
{ from: "user" },
);
expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => {
const program = buildProgram();
await program.parseAsync(["status"], { from: "user" });

View File

@@ -13,6 +13,8 @@ export function registerMessageReactionsCommands(message: Command, helpers: Mess
.option("--remove", "Remove reaction", false)
.option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false)
.option("--target-author <id>", "Signal reaction target author (uuid or phone)")
.option("--target-author-uuid <uuid>", "Signal reaction target author uuid")
.action(async (opts) => {
await helpers.runMessageAction("react", opts);
});

View File

@@ -8,6 +8,7 @@ import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
export type SignalAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
@@ -64,6 +65,19 @@ export type SignalAccountConfig = {
reactionNotifications?: SignalReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>;
/** Action toggles for message tool capabilities. */
actions?: {
/** Enable/disable sending reactions via message tool (default: true). */
reactions?: boolean;
};
/**
* Controls agent reaction behavior:
* - "off": No reactions
* - "ack": Only automatic ack reactions (👀 when processing)
* - "minimal": Agent can react sparingly (default)
* - "extensive": Agent can react liberally
*/
reactionLevel?: SignalReactionLevel;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};

View File

@@ -499,6 +499,13 @@ export const SignalAccountSchemaBase = z
mediaMaxMb: z.number().int().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();

View File

@@ -40,6 +40,7 @@ import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/a
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { recordInboundSession } from "../../channels/session.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
import { monitorWebChannel } from "../../channels/web/index.js";
@@ -269,6 +270,7 @@ export function createPluginRuntime(): PluginRuntime {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
},
imessage: {
monitorIMessageProvider,

View File

@@ -123,6 +123,8 @@ type TelegramMessageActions =
type ProbeSignal = typeof import("../../signal/probe.js").probeSignal;
type SendMessageSignal = typeof import("../../signal/send.js").sendMessageSignal;
type MonitorSignalProvider = typeof import("../../signal/index.js").monitorSignalProvider;
type SignalMessageActions =
typeof import("../../channels/plugins/actions/signal.js").signalMessageActions;
type MonitorIMessageProvider = typeof import("../../imessage/monitor.js").monitorIMessageProvider;
type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage;
type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage;
@@ -278,6 +280,7 @@ export type PluginRuntime = {
probeSignal: ProbeSignal;
sendMessageSignal: SendMessageSignal;
monitorSignalProvider: MonitorSignalProvider;
messageActions: SignalMessageActions;
};
imessage: {
monitorIMessageProvider: MonitorIMessageProvider;

View File

@@ -1,3 +1,5 @@
export { monitorSignalProvider } from "./monitor.js";
export { probeSignal } from "./probe.js";
export { sendMessageSignal } from "./send.js";
export { sendReactionSignal, removeReactionSignal } from "./send-reactions.js";
export { resolveSignalReactionLevel } from "./reaction-level.js";

View File

@@ -0,0 +1,71 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolveSignalAccount } from "./accounts.js";
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
export type ResolvedSignalReactionLevel = {
level: SignalReactionLevel;
/** Whether ACK reactions (e.g., 👀 when processing) are enabled. */
ackEnabled: boolean;
/** Whether agent-controlled reactions are enabled. */
agentReactionsEnabled: boolean;
/** Guidance level for agent reactions (minimal = sparse, extensive = liberal). */
agentReactionGuidance?: "minimal" | "extensive";
};
/**
* Resolve the effective reaction level and its implications for Signal.
*
* Levels:
* - "off": No reactions at all
* - "ack": Only automatic ack reactions (👀 when processing), no agent reactions
* - "minimal": Agent can react, but sparingly (default)
* - "extensive": Agent can react liberally
*/
export function resolveSignalReactionLevel(params: {
cfg: ClawdbotConfig;
accountId?: string;
}): ResolvedSignalReactionLevel {
const account = resolveSignalAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const level = (account.config.reactionLevel ?? "minimal") as SignalReactionLevel;
switch (level) {
case "off":
return {
level,
ackEnabled: false,
agentReactionsEnabled: false,
};
case "ack":
return {
level,
ackEnabled: true,
agentReactionsEnabled: false,
};
case "minimal":
return {
level,
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "minimal",
};
case "extensive":
return {
level,
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "extensive",
};
default:
// Fallback to minimal behavior
return {
level: "minimal",
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "minimal",
};
}
}

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const rpcMock = vi.fn();
const loadSendReactions = async () => await import("./send-reactions.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({}),
};
});
vi.mock("./accounts.js", () => ({
resolveSignalAccount: () => ({
accountId: "default",
enabled: true,
baseUrl: "http://signal.local",
configured: true,
config: { account: "+15550001111" },
}),
}));
vi.mock("./client.js", () => ({
signalRpcRequest: (...args: unknown[]) => rpcMock(...args),
}));
describe("sendReactionSignal", () => {
beforeEach(() => {
rpcMock.mockReset().mockResolvedValue({ timestamp: 123 });
vi.resetModules();
});
it("uses recipients array and targetAuthor for uuid dms", async () => {
const { sendReactionSignal } = await loadSendReactions();
await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥");
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(rpcMock).toHaveBeenCalledWith("sendReaction", expect.any(Object), expect.any(Object));
expect(params.recipients).toEqual(["123e4567-e89b-12d3-a456-426614174000"]);
expect(params.groupIds).toBeUndefined();
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
expect(params).not.toHaveProperty("recipient");
expect(params).not.toHaveProperty("groupId");
});
it("uses groupIds array and maps targetAuthorUuid", async () => {
const { sendReactionSignal } = await loadSendReactions();
await sendReactionSignal("", 123, "✅", {
groupId: "group-id",
targetAuthorUuid: "uuid:123e4567-e89b-12d3-a456-426614174000",
});
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(params.recipients).toBeUndefined();
expect(params.groupIds).toEqual(["group-id"]);
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
});
it("defaults targetAuthor to recipient for removals", async () => {
const { removeReactionSignal } = await loadSendReactions();
await removeReactionSignal("+15551230000", 456, "❌");
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(params.recipients).toEqual(["+15551230000"]);
expect(params.targetAuthor).toBe("+15551230000");
expect(params.remove).toBe(true);
});
});

View File

@@ -0,0 +1,195 @@
/**
* Signal reactions via signal-cli JSON-RPC API
*/
import { loadConfig } from "../config/config.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalRpcRequest } from "./client.js";
export type SignalReactionOpts = {
baseUrl?: string;
account?: string;
accountId?: string;
timeoutMs?: number;
targetAuthor?: string;
targetAuthorUuid?: string;
groupId?: string;
};
export type SignalReactionResult = {
ok: boolean;
timestamp?: number;
};
function normalizeSignalId(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.replace(/^signal:/i, "").trim();
}
function normalizeSignalUuid(raw: string): string {
const trimmed = normalizeSignalId(raw);
if (!trimmed) return "";
if (trimmed.toLowerCase().startsWith("uuid:")) {
return trimmed.slice("uuid:".length).trim();
}
return trimmed;
}
function resolveTargetAuthorParams(params: {
targetAuthor?: string;
targetAuthorUuid?: string;
fallback?: string;
}): { targetAuthor?: string } {
const candidates = [params.targetAuthor, params.targetAuthorUuid, params.fallback];
for (const candidate of candidates) {
const raw = candidate?.trim();
if (!raw) continue;
const normalized = normalizeSignalUuid(raw);
if (normalized) return { targetAuthor: normalized };
}
return {};
}
function resolveReactionRpcContext(
opts: SignalReactionOpts,
accountInfo?: ReturnType<typeof resolveSignalAccount>,
) {
const hasBaseUrl = Boolean(opts.baseUrl?.trim());
const hasAccount = Boolean(opts.account?.trim());
const resolvedAccount =
accountInfo ||
(!hasBaseUrl || !hasAccount
? resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
})
: undefined);
const baseUrl = opts.baseUrl?.trim() || resolvedAccount?.baseUrl;
if (!baseUrl) {
throw new Error("Signal base URL is required");
}
const account = opts.account?.trim() || resolvedAccount?.config.account?.trim();
return { baseUrl, account };
}
/**
* Send a Signal reaction to a message
* @param recipient - UUID or E.164 phone number of the message author
* @param targetTimestamp - Message ID (timestamp) to react to
* @param emoji - Emoji to react with
* @param opts - Optional account/connection overrides
*/
export async function sendReactionSignal(
recipient: string,
targetTimestamp: number,
emoji: string,
opts: SignalReactionOpts = {},
): Promise<SignalReactionResult> {
const accountInfo = resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
const { baseUrl, account } = resolveReactionRpcContext(opts, accountInfo);
const normalizedRecipient = normalizeSignalUuid(recipient);
const groupId = opts.groupId?.trim();
if (!normalizedRecipient && !groupId) {
throw new Error("Recipient or groupId is required for Signal reaction");
}
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
throw new Error("Valid targetTimestamp is required for Signal reaction");
}
if (!emoji?.trim()) {
throw new Error("Emoji is required for Signal reaction");
}
const targetAuthorParams = resolveTargetAuthorParams({
targetAuthor: opts.targetAuthor,
targetAuthorUuid: opts.targetAuthorUuid,
fallback: normalizedRecipient,
});
if (groupId && !targetAuthorParams.targetAuthor) {
throw new Error("targetAuthor is required for group reactions");
}
const params: Record<string, unknown> = {
emoji: emoji.trim(),
targetTimestamp,
...targetAuthorParams,
};
if (normalizedRecipient) params.recipients = [normalizedRecipient];
if (groupId) params.groupIds = [groupId];
if (account) params.account = account;
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return {
ok: true,
timestamp: result?.timestamp,
};
}
/**
* Remove a Signal reaction from a message
* @param recipient - UUID or E.164 phone number of the message author
* @param targetTimestamp - Message ID (timestamp) to remove reaction from
* @param emoji - Emoji to remove
* @param opts - Optional account/connection overrides
*/
export async function removeReactionSignal(
recipient: string,
targetTimestamp: number,
emoji: string,
opts: SignalReactionOpts = {},
): Promise<SignalReactionResult> {
const accountInfo = resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
const { baseUrl, account } = resolveReactionRpcContext(opts, accountInfo);
const normalizedRecipient = normalizeSignalUuid(recipient);
const groupId = opts.groupId?.trim();
if (!normalizedRecipient && !groupId) {
throw new Error("Recipient or groupId is required for Signal reaction removal");
}
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
throw new Error("Valid targetTimestamp is required for Signal reaction removal");
}
if (!emoji?.trim()) {
throw new Error("Emoji is required for Signal reaction removal");
}
const targetAuthorParams = resolveTargetAuthorParams({
targetAuthor: opts.targetAuthor,
targetAuthorUuid: opts.targetAuthorUuid,
fallback: normalizedRecipient,
});
if (groupId && !targetAuthorParams.targetAuthor) {
throw new Error("targetAuthor is required for group reaction removal");
}
const params: Record<string, unknown> = {
emoji: emoji.trim(),
targetTimestamp,
remove: true,
...targetAuthorParams,
};
if (normalizedRecipient) params.recipients = [normalizedRecipient];
if (groupId) params.groupIds = [groupId];
if (account) params.account = account;
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return {
ok: true,
timestamp: result?.timestamp,
};
}