fix: signal reactions
This commit is contained in:
@@ -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";
|
||||
|
||||
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