diff --git a/CHANGELOG.md b/CHANGELOG.md index f5707a46d..604acc8c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/signal.md b/docs/channels/signal.md index b015d02bf..1456b6b2d 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -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:` from pairing output; bare UUID works too). +- `messageId` is the Signal timestamp for the message you’re 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: targetAuthor=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..actions.reactions`, `channels.signal.accounts..reactionLevel`. + ## Delivery targets (CLI/cron) - DMs: `signal:+15551234567` (or plain E.164). +- UUID DMs: `uuid:` (or bare UUID). - Groups: `signal:group:`. - Usernames: `username:` (if supported by your Signal account). diff --git a/docs/cli/message.md b/docs/cli/message.md index 9129b307d..263cd6d0e 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -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:" \ diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 97c6f0695..dbd628f8d 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -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 = { @@ -42,7 +50,9 @@ export const signalPlugin: ChannelPlugin = { capabilities: { chatTypes: ["direct", "group"], media: true, + reactions: true, }, + actions: signalMessageActions, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, @@ -115,7 +125,7 @@ export const signalPlugin: ChannelPlugin = { normalizeTarget: normalizeSignalMessagingTarget, targetResolver: { looksLikeId: looksLikeSignalTargetId, - hint: "", + hint: "", }, }, setup: { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index e5c2fda18..2daafd086 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 776525658..f1c487470 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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({ diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index e2c1bb8bb..6552564e9 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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()), }; } diff --git a/src/channels/plugins/actions/signal.test.ts b/src/channels/plugins/actions/signal.test.ts new file mode 100644 index 000000000..7a79c2e90 --- /dev/null +++ b/src/channels/plugins/actions/signal.test.ts @@ -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, + }); + }); +}); diff --git a/src/channels/plugins/actions/signal.ts b/src/channels/plugins/actions/signal.ts new file mode 100644 index 000000000..e07f48b53 --- /dev/null +++ b/src/channels/plugins/actions/signal.ts @@ -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(["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}.`); + }, +}; diff --git a/src/channels/plugins/normalize/signal.test.ts b/src/channels/plugins/normalize/signal.test.ts new file mode 100644 index 000000000..6f4aee049 --- /dev/null +++ b/src/channels/plugins/normalize/signal.test.ts @@ -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); + }); +}); diff --git a/src/channels/plugins/normalize/signal.ts b/src/channels/plugins/normalize/signal.ts index 00e03443a..c8ff17da6 100644 --- a/src/channels/plugins/normalize/signal.ts +++ b/src/channels/plugins/normalize/signal.ts @@ -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); } diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 3dc01fcb2..f6b155554 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -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" }); diff --git a/src/cli/program/message/register.reactions.ts b/src/cli/program/message/register.reactions.ts index d8d635bd9..ab72a4506 100644 --- a/src/cli/program/message/register.reactions.ts +++ b/src/cli/program/message/register.reactions.ts @@ -13,6 +13,8 @@ export function registerMessageReactionsCommands(message: Command, helpers: Mess .option("--remove", "Remove reaction", false) .option("--participant ", "WhatsApp reaction participant") .option("--from-me", "WhatsApp reaction fromMe", false) + .option("--target-author ", "Signal reaction target author (uuid or phone)") + .option("--target-author-uuid ", "Signal reaction target author uuid") .action(async (opts) => { await helpers.runMessageAction("react", opts); }); diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 94cb82f3d..7cdaf0bf7 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -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; + /** 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; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 9d1eaa285..7489ad0d9 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 50e0a2d03..dc2c65340 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -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, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 40d936762..7df2bec81 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -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; diff --git a/src/signal/index.ts b/src/signal/index.ts index 60e88ab2b..29f241149 100644 --- a/src/signal/index.ts +++ b/src/signal/index.ts @@ -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"; diff --git a/src/signal/reaction-level.ts b/src/signal/reaction-level.ts new file mode 100644 index 000000000..7aa7eda7c --- /dev/null +++ b/src/signal/reaction-level.ts @@ -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", + }; + } +} diff --git a/src/signal/send-reactions.test.ts b/src/signal/send-reactions.test.ts new file mode 100644 index 000000000..0060053db --- /dev/null +++ b/src/signal/send-reactions.test.ts @@ -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(); + 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; + 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; + 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; + expect(params.recipients).toEqual(["+15551230000"]); + expect(params.targetAuthor).toBe("+15551230000"); + expect(params.remove).toBe(true); + }); +}); diff --git a/src/signal/send-reactions.ts b/src/signal/send-reactions.ts new file mode 100644 index 000000000..0caf606ea --- /dev/null +++ b/src/signal/send-reactions.ts @@ -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, +) { + 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 { + 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 = { + 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 { + 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 = { + 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, + }; +}