fix: signal reactions
This commit is contained in:
@@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Heartbeat: normalize target identifiers for consistent routing.
|
- Heartbeat: normalize target identifiers for consistent routing.
|
||||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
- 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)
|
- 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.
|
- 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: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
|
|||||||
@@ -105,8 +105,29 @@ Groups:
|
|||||||
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
||||||
- Signal-cli does not expose read receipts for groups.
|
- 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 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:<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)
|
## Delivery targets (CLI/cron)
|
||||||
- DMs: `signal:+15551234567` (or plain E.164).
|
- DMs: `signal:+15551234567` (or plain E.164).
|
||||||
|
- UUID DMs: `uuid:<id>` (or bare UUID).
|
||||||
- Groups: `signal:group:<groupId>`.
|
- Groups: `signal:group:<groupId>`.
|
||||||
- Usernames: `username:<name>` (if supported by your Signal account).
|
- Usernames: `username:<name>` (if supported by your Signal account).
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,12 @@ Name lookup:
|
|||||||
- Discord only: `--poll-duration-hours`, `--message`
|
- Discord only: `--poll-duration-hours`, `--message`
|
||||||
|
|
||||||
- `react`
|
- `react`
|
||||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
|
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
||||||
- Required: `--message-id`, `--target`
|
- 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)
|
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||||
- WhatsApp only: `--participant`, `--from-me`
|
- WhatsApp only: `--participant`, `--from-me`
|
||||||
|
- Signal group reactions: `--target-author` or `--target-author-uuid` required
|
||||||
|
|
||||||
- `reactions`
|
- `reactions`
|
||||||
- Channels: Discord/Google Chat/Slack
|
- Channels: Discord/Google Chat/Slack
|
||||||
@@ -213,6 +214,13 @@ clawdbot message react --channel slack \
|
|||||||
--target C123 --message-id 456 --emoji "✅"
|
--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:
|
Send Telegram inline buttons:
|
||||||
```
|
```
|
||||||
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ import {
|
|||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
signalOnboardingAdapter,
|
signalOnboardingAdapter,
|
||||||
SignalConfigSchema,
|
SignalConfigSchema,
|
||||||
|
type ChannelMessageActionAdapter,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
type ResolvedSignalAccount,
|
type ResolvedSignalAccount,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { getSignalRuntime } from "./runtime.js";
|
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");
|
const meta = getChatChannelMeta("signal");
|
||||||
|
|
||||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||||
@@ -42,7 +50,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
chatTypes: ["direct", "group"],
|
chatTypes: ["direct", "group"],
|
||||||
media: true,
|
media: true,
|
||||||
|
reactions: true,
|
||||||
},
|
},
|
||||||
|
actions: signalMessageActions,
|
||||||
streaming: {
|
streaming: {
|
||||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||||
},
|
},
|
||||||
@@ -115,7 +125,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
|||||||
normalizeTarget: normalizeSignalMessagingTarget,
|
normalizeTarget: normalizeSignalMessagingTarget,
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: looksLikeSignalTargetId,
|
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: {
|
setup: {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.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 { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||||
import { isSubagentSessionKey } from "../../routing/session-key.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
|
// Resolve channel-specific message actions for system prompt
|
||||||
const channelActions = runtimeChannel
|
const channelActions = runtimeChannel
|
||||||
? listChannelSupportedActions({
|
? listChannelSupportedActions({
|
||||||
@@ -313,6 +337,7 @@ export async function compactEmbeddedPiSessionDirect(
|
|||||||
ttsHint,
|
ttsHint,
|
||||||
promptMode,
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
|
reactionGuidance,
|
||||||
messageToolHints,
|
messageToolHints,
|
||||||
sandboxInfo,
|
sandboxInfo,
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
|
|||||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
||||||
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
||||||
|
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
|
||||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||||
@@ -255,14 +256,25 @@ export async function runEmbeddedAttempt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const reactionGuidance =
|
const reactionGuidance =
|
||||||
runtimeChannel === "telegram" && params.config
|
runtimeChannel && params.config
|
||||||
? (() => {
|
? (() => {
|
||||||
const resolved = resolveTelegramReactionLevel({
|
if (runtimeChannel === "telegram") {
|
||||||
cfg: params.config,
|
const resolved = resolveTelegramReactionLevel({
|
||||||
accountId: params.agentAccountId ?? undefined,
|
cfg: params.config,
|
||||||
});
|
accountId: params.agentAccountId ?? undefined,
|
||||||
const level = resolved.agentReactionGuidance;
|
});
|
||||||
return level ? { level, channel: "Telegram" } : 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;
|
: undefined;
|
||||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ function buildReactionSchema() {
|
|||||||
messageId: Type.Optional(Type.String()),
|
messageId: Type.Optional(Type.String()),
|
||||||
emoji: Type.Optional(Type.String()),
|
emoji: Type.Optional(Type.String()),
|
||||||
remove: Type.Optional(Type.Boolean()),
|
remove: Type.Optional(Type.Boolean()),
|
||||||
|
targetAuthor: Type.Optional(Type.String()),
|
||||||
|
targetAuthorUuid: Type.Optional(Type.String()),
|
||||||
|
groupId: Type.Optional(Type.String()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
151
src/channels/plugins/actions/signal.test.ts
Normal file
151
src/channels/plugins/actions/signal.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
src/channels/plugins/actions/signal.ts
Normal file
130
src/channels/plugins/actions/signal.ts
Normal 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}.`);
|
||||||
|
},
|
||||||
|
};
|
||||||
32
src/channels/plugins/normalize/signal.test.ts
Normal file
32
src/channels/plugins/normalize/signal.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,12 +19,30 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined
|
|||||||
const id = normalized.slice("u:".length).trim();
|
const id = normalized.slice("u:".length).trim();
|
||||||
return id ? `username:${id}`.toLowerCase() : undefined;
|
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();
|
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 {
|
export function looksLikeSignalTargetId(raw: string): boolean {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
|
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);
|
return /^\+?\d{3,}$/.test(trimmed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,28 @@ describe("cli program (smoke)", () => {
|
|||||||
expect(messageCommand).toHaveBeenCalled();
|
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 () => {
|
it("runs status command", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["status"], { from: "user" });
|
await program.parseAsync(["status"], { from: "user" });
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export function registerMessageReactionsCommands(message: Command, helpers: Mess
|
|||||||
.option("--remove", "Remove reaction", false)
|
.option("--remove", "Remove reaction", false)
|
||||||
.option("--participant <id>", "WhatsApp reaction participant")
|
.option("--participant <id>", "WhatsApp reaction participant")
|
||||||
.option("--from-me", "WhatsApp reaction fromMe", false)
|
.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) => {
|
.action(async (opts) => {
|
||||||
await helpers.runMessageAction("react", opts);
|
await helpers.runMessageAction("react", opts);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
|||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
|
|
||||||
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||||
|
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
|
||||||
|
|
||||||
export type SignalAccountConfig = {
|
export type SignalAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
@@ -64,6 +65,19 @@ export type SignalAccountConfig = {
|
|||||||
reactionNotifications?: SignalReactionNotificationMode;
|
reactionNotifications?: SignalReactionNotificationMode;
|
||||||
/** Allowlist for reaction notifications when mode is allowlist. */
|
/** Allowlist for reaction notifications when mode is allowlist. */
|
||||||
reactionAllowlist?: Array<string | number>;
|
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 visibility settings for this channel. */
|
||||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -499,6 +499,13 @@ export const SignalAccountSchemaBase = z
|
|||||||
mediaMaxMb: z.number().int().positive().optional(),
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).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,
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/a
|
|||||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||||
import { recordInboundSession } from "../../channels/session.js";
|
import { recordInboundSession } from "../../channels/session.js";
|
||||||
import { discordMessageActions } from "../../channels/plugins/actions/discord.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 { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
|
||||||
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
|
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
|
||||||
import { monitorWebChannel } from "../../channels/web/index.js";
|
import { monitorWebChannel } from "../../channels/web/index.js";
|
||||||
@@ -269,6 +270,7 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
probeSignal,
|
probeSignal,
|
||||||
sendMessageSignal,
|
sendMessageSignal,
|
||||||
monitorSignalProvider,
|
monitorSignalProvider,
|
||||||
|
messageActions: signalMessageActions,
|
||||||
},
|
},
|
||||||
imessage: {
|
imessage: {
|
||||||
monitorIMessageProvider,
|
monitorIMessageProvider,
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ type TelegramMessageActions =
|
|||||||
type ProbeSignal = typeof import("../../signal/probe.js").probeSignal;
|
type ProbeSignal = typeof import("../../signal/probe.js").probeSignal;
|
||||||
type SendMessageSignal = typeof import("../../signal/send.js").sendMessageSignal;
|
type SendMessageSignal = typeof import("../../signal/send.js").sendMessageSignal;
|
||||||
type MonitorSignalProvider = typeof import("../../signal/index.js").monitorSignalProvider;
|
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 MonitorIMessageProvider = typeof import("../../imessage/monitor.js").monitorIMessageProvider;
|
||||||
type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage;
|
type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage;
|
||||||
type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage;
|
type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage;
|
||||||
@@ -278,6 +280,7 @@ export type PluginRuntime = {
|
|||||||
probeSignal: ProbeSignal;
|
probeSignal: ProbeSignal;
|
||||||
sendMessageSignal: SendMessageSignal;
|
sendMessageSignal: SendMessageSignal;
|
||||||
monitorSignalProvider: MonitorSignalProvider;
|
monitorSignalProvider: MonitorSignalProvider;
|
||||||
|
messageActions: SignalMessageActions;
|
||||||
};
|
};
|
||||||
imessage: {
|
imessage: {
|
||||||
monitorIMessageProvider: MonitorIMessageProvider;
|
monitorIMessageProvider: MonitorIMessageProvider;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export { monitorSignalProvider } from "./monitor.js";
|
export { monitorSignalProvider } from "./monitor.js";
|
||||||
export { probeSignal } from "./probe.js";
|
export { probeSignal } from "./probe.js";
|
||||||
export { sendMessageSignal } from "./send.js";
|
export { sendMessageSignal } from "./send.js";
|
||||||
|
export { sendReactionSignal, removeReactionSignal } from "./send-reactions.js";
|
||||||
|
export { resolveSignalReactionLevel } from "./reaction-level.js";
|
||||||
|
|||||||
71
src/signal/reaction-level.ts
Normal file
71
src/signal/reaction-level.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/signal/send-reactions.test.ts
Normal file
69
src/signal/send-reactions.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
195
src/signal/send-reactions.ts
Normal file
195
src/signal/send-reactions.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user