fix: signal reactions
This commit is contained in:
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();
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user