refactor(signal): move Signal channel code to extensions/signal/src/ (#45531)
Move all Signal channel implementation files from src/signal/ to extensions/signal/src/ and replace originals with re-export shims. This continues the channel plugin migration pattern used by other extensions, keeping backward compatibility via shims while the real code lives in the extension. - Copy 32 .ts files (source + tests) to extensions/signal/src/ - Transform all relative import paths for the new location - Create 2-line re-export shims in src/signal/ for each moved file - Preserve existing extension files (channel.ts, runtime.ts, etc.) - Change tsconfig.plugin-sdk.dts.json rootDir from "src" to "." to support cross-boundary re-exports from extensions/
This commit is contained in:
69
extensions/signal/src/accounts.ts
Normal file
69
extensions/signal/src/accounts.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { SignalAccountConfig } from "../../../src/config/types.js";
|
||||
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
|
||||
export type ResolvedSignalAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
configured: boolean;
|
||||
config: SignalAccountConfig;
|
||||
};
|
||||
|
||||
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("signal");
|
||||
export const listSignalAccountIds = listAccountIds;
|
||||
export const resolveDefaultSignalAccountId = resolveDefaultAccountId;
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): SignalAccountConfig | undefined {
|
||||
return resolveAccountEntry(cfg.channels?.signal?.accounts, accountId);
|
||||
}
|
||||
|
||||
function mergeSignalAccountConfig(cfg: OpenClawConfig, accountId: string): SignalAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.signal ?? {}) as SignalAccountConfig & {
|
||||
accounts?: unknown;
|
||||
};
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveSignalAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedSignalAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.channels?.signal?.enabled !== false;
|
||||
const merged = mergeSignalAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const host = merged.httpHost?.trim() || "127.0.0.1";
|
||||
const port = merged.httpPort ?? 8080;
|
||||
const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`;
|
||||
const configured = Boolean(
|
||||
merged.account?.trim() ||
|
||||
merged.httpUrl?.trim() ||
|
||||
merged.cliPath?.trim() ||
|
||||
merged.httpHost?.trim() ||
|
||||
typeof merged.httpPort === "number" ||
|
||||
typeof merged.autoStart === "boolean",
|
||||
);
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
baseUrl,
|
||||
configured,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledSignalAccounts(cfg: OpenClawConfig): ResolvedSignalAccount[] {
|
||||
return listSignalAccountIds(cfg)
|
||||
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
67
extensions/signal/src/client.test.ts
Normal file
67
extensions/signal/src/client.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWithTimeoutMock = vi.fn();
|
||||
const resolveFetchMock = vi.fn();
|
||||
|
||||
vi.mock("../../../src/infra/fetch.js", () => ({
|
||||
resolveFetch: (...args: unknown[]) => resolveFetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/infra/secure-random.js", () => ({
|
||||
generateSecureUuid: () => "test-id",
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/utils/fetch-timeout.js", () => ({
|
||||
fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
|
||||
function rpcResponse(body: unknown, status = 200): Response {
|
||||
if (typeof body === "string") {
|
||||
return new Response(body, { status });
|
||||
}
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}
|
||||
|
||||
describe("signalRpcRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveFetchMock.mockReturnValue(vi.fn());
|
||||
});
|
||||
|
||||
it("returns parsed RPC result", async () => {
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
rpcResponse({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" }),
|
||||
);
|
||||
|
||||
const result = await signalRpcRequest<{ version: string }>("version", undefined, {
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ version: "0.13.22" });
|
||||
});
|
||||
|
||||
it("throws a wrapped error when RPC response JSON is malformed", async () => {
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse("not-json", 502));
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "Signal RPC returned malformed JSON (status 502)",
|
||||
cause: expect.any(SyntaxError),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when RPC response envelope has neither result nor error", async () => {
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse({ jsonrpc: "2.0", id: "test-id" }));
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
}),
|
||||
).rejects.toThrow("Signal RPC returned invalid response envelope (status 200)");
|
||||
});
|
||||
});
|
||||
215
extensions/signal/src/client.ts
Normal file
215
extensions/signal/src/client.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { resolveFetch } from "../../../src/infra/fetch.js";
|
||||
import { generateSecureUuid } from "../../../src/infra/secure-random.js";
|
||||
import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js";
|
||||
|
||||
export type SignalRpcOptions = {
|
||||
baseUrl: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type SignalRpcError = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type SignalRpcResponse<T> = {
|
||||
jsonrpc?: string;
|
||||
result?: T;
|
||||
error?: SignalRpcError;
|
||||
id?: string | number | null;
|
||||
};
|
||||
|
||||
export type SignalSseEvent = {
|
||||
event?: string;
|
||||
data?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Signal base URL is required");
|
||||
}
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
return `http://${trimmed}`.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function getRequiredFetch(): typeof fetch {
|
||||
const fetchImpl = resolveFetch();
|
||||
if (!fetchImpl) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
return fetchImpl;
|
||||
}
|
||||
|
||||
function parseSignalRpcResponse<T>(text: string, status: number): SignalRpcResponse<T> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw new Error(`Signal RPC returned malformed JSON (status ${status})`, { cause: err });
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error(`Signal RPC returned invalid response envelope (status ${status})`);
|
||||
}
|
||||
|
||||
const rpc = parsed as SignalRpcResponse<T>;
|
||||
const hasResult = Object.hasOwn(rpc, "result");
|
||||
if (!rpc.error && !hasResult) {
|
||||
throw new Error(`Signal RPC returned invalid response envelope (status ${status})`);
|
||||
}
|
||||
return rpc;
|
||||
}
|
||||
|
||||
export async function signalRpcRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
opts: SignalRpcOptions,
|
||||
): Promise<T> {
|
||||
const baseUrl = normalizeBaseUrl(opts.baseUrl);
|
||||
const id = generateSecureUuid();
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params,
|
||||
id,
|
||||
});
|
||||
const res = await fetchWithTimeout(
|
||||
`${baseUrl}/api/v1/rpc`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
},
|
||||
opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
getRequiredFetch(),
|
||||
);
|
||||
if (res.status === 201) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
throw new Error(`Signal RPC empty response (status ${res.status})`);
|
||||
}
|
||||
const parsed = parseSignalRpcResponse<T>(text, res.status);
|
||||
if (parsed.error) {
|
||||
const code = parsed.error.code ?? "unknown";
|
||||
const msg = parsed.error.message ?? "Signal RPC error";
|
||||
throw new Error(`Signal RPC ${code}: ${msg}`);
|
||||
}
|
||||
return parsed.result as T;
|
||||
}
|
||||
|
||||
export async function signalCheck(
|
||||
baseUrl: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${normalized}/api/v1/check`,
|
||||
{ method: "GET" },
|
||||
timeoutMs,
|
||||
getRequiredFetch(),
|
||||
);
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, status: res.status, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamSignalEvents(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
onEvent: (event: SignalSseEvent) => void;
|
||||
}): Promise<void> {
|
||||
const baseUrl = normalizeBaseUrl(params.baseUrl);
|
||||
const url = new URL(`${baseUrl}/api/v1/events`);
|
||||
if (params.account) {
|
||||
url.searchParams.set("account", params.account);
|
||||
}
|
||||
|
||||
const fetchImpl = resolveFetch();
|
||||
if (!fetchImpl) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
const res = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: params.abortSignal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Signal SSE failed (${res.status} ${res.statusText || "error"})`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let currentEvent: SignalSseEvent = {};
|
||||
|
||||
const flushEvent = () => {
|
||||
if (!currentEvent.data && !currentEvent.event && !currentEvent.id) {
|
||||
return;
|
||||
}
|
||||
params.onEvent({
|
||||
event: currentEvent.event,
|
||||
data: currentEvent.data,
|
||||
id: currentEvent.id,
|
||||
});
|
||||
currentEvent = {};
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let lineEnd = buffer.indexOf("\n");
|
||||
while (lineEnd !== -1) {
|
||||
let line = buffer.slice(0, lineEnd);
|
||||
buffer = buffer.slice(lineEnd + 1);
|
||||
if (line.endsWith("\r")) {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
|
||||
if (line === "") {
|
||||
flushEvent();
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(":")) {
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
continue;
|
||||
}
|
||||
const [rawField, ...rest] = line.split(":");
|
||||
const field = rawField.trim();
|
||||
const rawValue = rest.join(":");
|
||||
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
|
||||
if (field === "event") {
|
||||
currentEvent.event = value;
|
||||
} else if (field === "data") {
|
||||
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${value}` : value;
|
||||
} else if (field === "id") {
|
||||
currentEvent.id = value;
|
||||
}
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
flushEvent();
|
||||
}
|
||||
147
extensions/signal/src/daemon.ts
Normal file
147
extensions/signal/src/daemon.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
|
||||
export type SignalDaemonOpts = {
|
||||
cliPath: string;
|
||||
account?: string;
|
||||
httpHost: string;
|
||||
httpPort: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
export type SignalDaemonHandle = {
|
||||
pid?: number;
|
||||
stop: () => void;
|
||||
exited: Promise<SignalDaemonExitEvent>;
|
||||
isExited: () => boolean;
|
||||
};
|
||||
|
||||
export type SignalDaemonExitEvent = {
|
||||
source: "process" | "spawn-error";
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
export function formatSignalDaemonExit(exit: SignalDaemonExitEvent): string {
|
||||
return `signal daemon exited (source=${exit.source} code=${String(exit.code ?? "null")} signal=${String(exit.signal ?? "null")})`;
|
||||
}
|
||||
|
||||
export function classifySignalCliLogLine(line: string): "log" | "error" | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
// signal-cli commonly writes all logs to stderr; treat severity explicitly.
|
||||
if (/\b(ERROR|WARN|WARNING)\b/.test(trimmed)) {
|
||||
return "error";
|
||||
}
|
||||
// Some signal-cli failures are not tagged with WARN/ERROR but should still be surfaced loudly.
|
||||
if (/\b(FAILED|SEVERE|EXCEPTION)\b/i.test(trimmed)) {
|
||||
return "error";
|
||||
}
|
||||
return "log";
|
||||
}
|
||||
|
||||
function bindSignalCliOutput(params: {
|
||||
stream: NodeJS.ReadableStream | null | undefined;
|
||||
log: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}): void {
|
||||
params.stream?.on("data", (data) => {
|
||||
for (const line of data.toString().split(/\r?\n/)) {
|
||||
const kind = classifySignalCliLogLine(line);
|
||||
if (kind === "log") {
|
||||
params.log(`signal-cli: ${line.trim()}`);
|
||||
} else if (kind === "error") {
|
||||
params.error(`signal-cli: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildDaemonArgs(opts: SignalDaemonOpts): string[] {
|
||||
const args: string[] = [];
|
||||
if (opts.account) {
|
||||
args.push("-a", opts.account);
|
||||
}
|
||||
args.push("daemon");
|
||||
args.push("--http", `${opts.httpHost}:${opts.httpPort}`);
|
||||
args.push("--no-receive-stdout");
|
||||
|
||||
if (opts.receiveMode) {
|
||||
args.push("--receive-mode", opts.receiveMode);
|
||||
}
|
||||
if (opts.ignoreAttachments) {
|
||||
args.push("--ignore-attachments");
|
||||
}
|
||||
if (opts.ignoreStories) {
|
||||
args.push("--ignore-stories");
|
||||
}
|
||||
if (opts.sendReadReceipts) {
|
||||
args.push("--send-read-receipts");
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
|
||||
const args = buildDaemonArgs(opts);
|
||||
const child = spawn(opts.cliPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const log = opts.runtime?.log ?? (() => {});
|
||||
const error = opts.runtime?.error ?? (() => {});
|
||||
let exited = false;
|
||||
let settledExit = false;
|
||||
let resolveExit!: (value: SignalDaemonExitEvent) => void;
|
||||
const exitedPromise = new Promise<SignalDaemonExitEvent>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const settleExit = (value: SignalDaemonExitEvent) => {
|
||||
if (settledExit) {
|
||||
return;
|
||||
}
|
||||
settledExit = true;
|
||||
exited = true;
|
||||
resolveExit(value);
|
||||
};
|
||||
|
||||
bindSignalCliOutput({ stream: child.stdout, log, error });
|
||||
bindSignalCliOutput({ stream: child.stderr, log, error });
|
||||
child.once("exit", (code, signal) => {
|
||||
settleExit({
|
||||
source: "process",
|
||||
code: typeof code === "number" ? code : null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
error(
|
||||
formatSignalDaemonExit({ source: "process", code: code ?? null, signal: signal ?? null }),
|
||||
);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
settleExit({
|
||||
source: "process",
|
||||
code: typeof code === "number" ? code : null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
error(`signal-cli spawn error: ${String(err)}`);
|
||||
settleExit({ source: "spawn-error", code: null, signal: null });
|
||||
});
|
||||
|
||||
return {
|
||||
pid: child.pid ?? undefined,
|
||||
exited: exitedPromise,
|
||||
isExited: () => exited,
|
||||
stop: () => {
|
||||
if (!child.killed && !exited) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
388
extensions/signal/src/format.chunking.test.ts
Normal file
388
extensions/signal/src/format.chunking.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalTextChunks } from "./format.js";
|
||||
|
||||
function expectChunkStyleRangesInBounds(chunks: ReturnType<typeof markdownToSignalTextChunks>) {
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
expect(style.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("splitSignalFormattedText", () => {
|
||||
// We test the internal chunking behavior via markdownToSignalTextChunks with
|
||||
// pre-rendered SignalFormattedText. The helper is not exported, so we test
|
||||
// it indirectly through integration tests and by constructing scenarios that
|
||||
// exercise the splitting logic.
|
||||
|
||||
describe("style-aware splitting - basic text", () => {
|
||||
it("text with no styles splits correctly at whitespace", () => {
|
||||
// Create text that exceeds limit and must be split
|
||||
const limit = 20;
|
||||
const markdown = "hello world this is a test";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
// Verify all text is preserved (joined chunks should contain all words)
|
||||
const joinedText = chunks.map((c) => c.text).join(" ");
|
||||
expect(joinedText).toContain("hello");
|
||||
expect(joinedText).toContain("world");
|
||||
expect(joinedText).toContain("test");
|
||||
});
|
||||
|
||||
it("empty text returns empty array", () => {
|
||||
// Empty input produces no chunks (not an empty chunk)
|
||||
const chunks = markdownToSignalTextChunks("", 100);
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
|
||||
it("text under limit returns single chunk unchanged", () => {
|
||||
const markdown = "short text";
|
||||
const chunks = markdownToSignalTextChunks(markdown, 100);
|
||||
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0].text).toBe("short text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("style-aware splitting - style preservation", () => {
|
||||
it("style fully within first chunk stays in first chunk", () => {
|
||||
// Create a message where bold text is in the first chunk
|
||||
const limit = 30;
|
||||
const markdown = "**bold** word more words here that exceed limit";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
// First chunk should contain the bold style
|
||||
const firstChunk = chunks[0];
|
||||
expect(firstChunk.text).toContain("bold");
|
||||
expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true);
|
||||
// The bold style should start at position 0 in the first chunk
|
||||
const boldStyle = firstChunk.styles.find((s) => s.style === "BOLD");
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(boldStyle!.start).toBe(0);
|
||||
expect(boldStyle!.length).toBe(4); // "bold"
|
||||
});
|
||||
|
||||
it("style fully within second chunk has offset adjusted to chunk-local position", () => {
|
||||
// Create a message where the styled text is in the second chunk
|
||||
const limit = 30;
|
||||
const markdown = "some filler text here **bold** at the end";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
// Find the chunk containing "bold"
|
||||
const chunkWithBold = chunks.find((c) => c.text.includes("bold"));
|
||||
expect(chunkWithBold).toBeDefined();
|
||||
expect(chunkWithBold!.styles.some((s) => s.style === "BOLD")).toBe(true);
|
||||
|
||||
// The bold style should have chunk-local offset (not original text offset)
|
||||
const boldStyle = chunkWithBold!.styles.find((s) => s.style === "BOLD");
|
||||
expect(boldStyle).toBeDefined();
|
||||
// The offset should be the position within this chunk, not the original text
|
||||
const boldPos = chunkWithBold!.text.indexOf("bold");
|
||||
expect(boldStyle!.start).toBe(boldPos);
|
||||
expect(boldStyle!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("style spanning chunk boundary is split into two ranges", () => {
|
||||
// Create text where a styled span crosses the chunk boundary
|
||||
const limit = 15;
|
||||
// "hello **bold text here** end" - the bold spans across chunk boundary
|
||||
const markdown = "hello **boldtexthere** end";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Both chunks should have BOLD styles if the span was split
|
||||
const chunksWithBold = chunks.filter((c) => c.styles.some((s) => s.style === "BOLD"));
|
||||
// At least one chunk should have the bold style
|
||||
expect(chunksWithBold.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// For each chunk with bold, verify the style range is valid for that chunk
|
||||
for (const chunk of chunksWithBold) {
|
||||
for (const style of chunk.styles.filter((s) => s.style === "BOLD")) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("style starting exactly at split point goes entirely to second chunk", () => {
|
||||
// Create text where style starts right at where we'd split
|
||||
const limit = 10;
|
||||
const markdown = "abcdefghi **bold**";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Find chunk with bold
|
||||
const chunkWithBold = chunks.find((c) => c.styles.some((s) => s.style === "BOLD"));
|
||||
expect(chunkWithBold).toBeDefined();
|
||||
|
||||
// Verify the bold style is valid within its chunk
|
||||
const boldStyle = chunkWithBold!.styles.find((s) => s.style === "BOLD");
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(boldStyle!.start).toBeGreaterThanOrEqual(0);
|
||||
expect(boldStyle!.start + boldStyle!.length).toBeLessThanOrEqual(chunkWithBold!.text.length);
|
||||
});
|
||||
|
||||
it("style ending exactly at split point stays entirely in first chunk", () => {
|
||||
const limit = 10;
|
||||
const markdown = "**bold** rest of text";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// First chunk should have the complete bold style
|
||||
const firstChunk = chunks[0];
|
||||
if (firstChunk.text.includes("bold")) {
|
||||
const boldStyle = firstChunk.styles.find((s) => s.style === "BOLD");
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(boldStyle!.start + boldStyle!.length).toBeLessThanOrEqual(firstChunk.text.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("multiple styles, some spanning boundary, some not", () => {
|
||||
const limit = 25;
|
||||
// Mix of styles: italic at start, bold spanning boundary, monospace at end
|
||||
const markdown = "_italic_ some text **bold text** and `code`";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Verify all style ranges are valid within their respective chunks
|
||||
expectChunkStyleRangesInBounds(chunks);
|
||||
|
||||
// Collect all styles across chunks
|
||||
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
|
||||
// We should have at least italic, bold, and monospace somewhere
|
||||
expect(allStyles).toContain("ITALIC");
|
||||
expect(allStyles).toContain("BOLD");
|
||||
expect(allStyles).toContain("MONOSPACE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("style-aware splitting - edge cases", () => {
|
||||
it("handles zero-length text with styles gracefully", () => {
|
||||
// Edge case: empty markdown produces no chunks
|
||||
const chunks = markdownToSignalTextChunks("", 100);
|
||||
expect(chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles text that splits exactly at limit", () => {
|
||||
const limit = 10;
|
||||
const markdown = "1234567890"; // exactly 10 chars
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0].text).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("preserves style through whitespace trimming", () => {
|
||||
const limit = 30;
|
||||
const markdown = "**bold** some text that is longer than limit";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// Bold should be preserved in first chunk
|
||||
const firstChunk = chunks[0];
|
||||
if (firstChunk.text.includes("bold")) {
|
||||
expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles repeated substrings correctly (no indexOf fragility)", () => {
|
||||
// This test exposes the fragility of using indexOf to find chunk positions.
|
||||
// If the same substring appears multiple times, indexOf finds the first
|
||||
// occurrence, not necessarily the correct one.
|
||||
const limit = 20;
|
||||
// "word" appears multiple times - indexOf("word") would always find first
|
||||
const markdown = "word **bold word** word more text here to chunk";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// Verify chunks are under limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// Find chunk(s) with bold style
|
||||
const chunksWithBold = chunks.filter((c) => c.styles.some((s) => s.style === "BOLD"));
|
||||
expect(chunksWithBold.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The bold style should correctly cover "bold word" (or part of it if split)
|
||||
// and NOT incorrectly point to the first "word" in the text
|
||||
for (const chunk of chunksWithBold) {
|
||||
for (const style of chunk.styles.filter((s) => s.style === "BOLD")) {
|
||||
const styledText = chunk.text.slice(style.start, style.start + style.length);
|
||||
// The styled text should be part of "bold word", not the initial "word"
|
||||
expect(styledText).toMatch(/^(bold( word)?|word)$/);
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("handles chunk that starts with whitespace after split", () => {
|
||||
// When text is split at whitespace, the next chunk might have leading
|
||||
// whitespace trimmed. Styles must account for this.
|
||||
const limit = 15;
|
||||
const markdown = "some text **bold** at end";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// All style ranges must be valid
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("deterministically tracks position without indexOf fragility", () => {
|
||||
// This test ensures the chunker doesn't rely on finding chunks via indexOf
|
||||
// which can fail when chunkText trims whitespace or when duplicates exist.
|
||||
// Create text with lots of whitespace and repeated patterns.
|
||||
const limit = 25;
|
||||
const markdown = "aaa **bold** aaa **bold** aaa extra text to force split";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// Multiple chunks expected
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// All chunks should respect limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// All style ranges must be valid within their chunks
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
// The styled text at that position should actually be "bold"
|
||||
if (style.style === "BOLD") {
|
||||
const styledText = chunk.text.slice(style.start, style.start + style.length);
|
||||
expect(styledText).toBe("bold");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToSignalTextChunks", () => {
|
||||
describe("link expansion chunk limit", () => {
|
||||
it("does not exceed chunk limit after link expansion", () => {
|
||||
// Create text that is close to limit, with a link that will expand
|
||||
const limit = 100;
|
||||
// Create text that's 90 chars, leaving only 10 chars of headroom
|
||||
const filler = "x".repeat(80);
|
||||
// This link will expand from "[link](url)" to "link (https://example.com/very/long/path)"
|
||||
const markdown = `${filler} [link](https://example.com/very/long/path/that/will/exceed/limit)`;
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles multiple links near chunk boundary", () => {
|
||||
const limit = 100;
|
||||
const filler = "x".repeat(60);
|
||||
const markdown = `${filler} [a](https://a.com) [b](https://b.com) [c](https://c.com)`;
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("link expansion with style preservation", () => {
|
||||
it("long message with links that expand beyond limit preserves all text", () => {
|
||||
const limit = 80;
|
||||
const filler = "a".repeat(50);
|
||||
const markdown = `${filler} [click here](https://example.com/very/long/path/to/page) more text`;
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// All chunks should be under limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// Combined text should contain all original content
|
||||
const combined = chunks.map((c) => c.text).join("");
|
||||
expect(combined).toContain(filler);
|
||||
expect(combined).toContain("click here");
|
||||
expect(combined).toContain("example.com");
|
||||
});
|
||||
|
||||
it("styles (bold, italic) survive chunking correctly after link expansion", () => {
|
||||
const limit = 60;
|
||||
const markdown =
|
||||
"**bold start** text [link](https://example.com/path) _italic_ more content here to force chunking";
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// Should have multiple chunks
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// All style ranges should be valid within their chunks
|
||||
expectChunkStyleRangesInBounds(chunks);
|
||||
|
||||
// Verify styles exist somewhere
|
||||
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
|
||||
expect(allStyles).toContain("BOLD");
|
||||
expect(allStyles).toContain("ITALIC");
|
||||
});
|
||||
|
||||
it("multiple links near chunk boundary all get properly chunked", () => {
|
||||
const limit = 50;
|
||||
const markdown =
|
||||
"[first](https://first.com/long/path) [second](https://second.com/another/path) [third](https://third.com)";
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// All chunks should respect limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// All link labels should appear somewhere
|
||||
const combined = chunks.map((c) => c.text).join("");
|
||||
expect(combined).toContain("first");
|
||||
expect(combined).toContain("second");
|
||||
expect(combined).toContain("third");
|
||||
});
|
||||
|
||||
it("preserves spoiler style through link expansion and chunking", () => {
|
||||
const limit = 40;
|
||||
const markdown =
|
||||
"||secret content|| and [link](https://example.com/path) with more text to chunk";
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// All chunks should respect limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// Spoiler style should exist and be valid
|
||||
const chunkWithSpoiler = chunks.find((c) => c.styles.some((s) => s.style === "SPOILER"));
|
||||
expect(chunkWithSpoiler).toBeDefined();
|
||||
|
||||
const spoilerStyle = chunkWithSpoiler!.styles.find((s) => s.style === "SPOILER");
|
||||
expect(spoilerStyle).toBeDefined();
|
||||
expect(spoilerStyle!.start).toBeGreaterThanOrEqual(0);
|
||||
expect(spoilerStyle!.start + spoilerStyle!.length).toBeLessThanOrEqual(
|
||||
chunkWithSpoiler!.text.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
extensions/signal/src/format.links.test.ts
Normal file
35
extensions/signal/src/format.links.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalText } from "./format.js";
|
||||
|
||||
describe("markdownToSignalText", () => {
|
||||
describe("duplicate URL display", () => {
|
||||
it("does not duplicate URL for normalized equivalent labels", () => {
|
||||
const equivalentCases = [
|
||||
{ input: "[selfh.st](http://selfh.st)", expected: "selfh.st" },
|
||||
{ input: "[example.com](https://example.com)", expected: "example.com" },
|
||||
{ input: "[www.example.com](https://example.com)", expected: "www.example.com" },
|
||||
{ input: "[example.com](https://example.com/)", expected: "example.com" },
|
||||
{ input: "[example.com](https://example.com///)", expected: "example.com" },
|
||||
{ input: "[example.com](https://www.example.com)", expected: "example.com" },
|
||||
{ input: "[EXAMPLE.COM](https://example.com)", expected: "EXAMPLE.COM" },
|
||||
{ input: "[example.com/page](https://example.com/page)", expected: "example.com/page" },
|
||||
] as const;
|
||||
|
||||
for (const { input, expected } of equivalentCases) {
|
||||
const res = markdownToSignalText(input);
|
||||
expect(res.text).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("still shows URL when label is meaningfully different", () => {
|
||||
const res = markdownToSignalText("[click here](https://example.com)");
|
||||
expect(res.text).toBe("click here (https://example.com)");
|
||||
});
|
||||
|
||||
it("handles URL with path - should show URL when label is just domain", () => {
|
||||
// Label is just domain, URL has path - these are meaningfully different
|
||||
const res = markdownToSignalText("[example.com](https://example.com/page)");
|
||||
expect(res.text).toBe("example.com (https://example.com/page)");
|
||||
});
|
||||
});
|
||||
});
|
||||
68
extensions/signal/src/format.test.ts
Normal file
68
extensions/signal/src/format.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalText } from "./format.js";
|
||||
|
||||
describe("markdownToSignalText", () => {
|
||||
it("renders inline styles", () => {
|
||||
const res = markdownToSignalText("hi _there_ **boss** ~~nope~~ `code`");
|
||||
|
||||
expect(res.text).toBe("hi there boss nope code");
|
||||
expect(res.styles).toEqual([
|
||||
{ start: 3, length: 5, style: "ITALIC" },
|
||||
{ start: 9, length: 4, style: "BOLD" },
|
||||
{ start: 14, length: 4, style: "STRIKETHROUGH" },
|
||||
{ start: 19, length: 4, style: "MONOSPACE" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders links as label plus url when needed", () => {
|
||||
const res = markdownToSignalText("see [docs](https://example.com) and https://example.com");
|
||||
|
||||
expect(res.text).toBe("see docs (https://example.com) and https://example.com");
|
||||
expect(res.styles).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps style offsets correct with multiple expanded links", () => {
|
||||
const markdown =
|
||||
"[first](https://example.com/first) **bold** [second](https://example.com/second)";
|
||||
const res = markdownToSignalText(markdown);
|
||||
|
||||
const expectedText =
|
||||
"first (https://example.com/first) bold second (https://example.com/second)";
|
||||
|
||||
expect(res.text).toBe(expectedText);
|
||||
expect(res.styles).toEqual([{ start: expectedText.indexOf("bold"), length: 4, style: "BOLD" }]);
|
||||
});
|
||||
|
||||
it("applies spoiler styling", () => {
|
||||
const res = markdownToSignalText("hello ||secret|| world");
|
||||
|
||||
expect(res.text).toBe("hello secret world");
|
||||
expect(res.styles).toEqual([{ start: 6, length: 6, style: "SPOILER" }]);
|
||||
});
|
||||
|
||||
it("renders fenced code blocks with monospaced styles", () => {
|
||||
const res = markdownToSignalText("before\n\n```\nconst x = 1;\n```\n\nafter");
|
||||
|
||||
const prefix = "before\n\n";
|
||||
const code = "const x = 1;\n";
|
||||
const suffix = "\nafter";
|
||||
|
||||
expect(res.text).toBe(`${prefix}${code}${suffix}`);
|
||||
expect(res.styles).toEqual([{ start: prefix.length, length: code.length, style: "MONOSPACE" }]);
|
||||
});
|
||||
|
||||
it("renders lists without extra block markup", () => {
|
||||
const res = markdownToSignalText("- one\n- two");
|
||||
|
||||
expect(res.text).toBe("• one\n• two");
|
||||
expect(res.styles).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses UTF-16 code units for offsets", () => {
|
||||
const res = markdownToSignalText("😀 **bold**");
|
||||
|
||||
const prefix = "😀 ";
|
||||
expect(res.text).toBe(`${prefix}bold`);
|
||||
expect(res.styles).toEqual([{ start: prefix.length, length: 4, style: "BOLD" }]);
|
||||
});
|
||||
});
|
||||
397
extensions/signal/src/format.ts
Normal file
397
extensions/signal/src/format.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import type { MarkdownTableMode } from "../../../src/config/types.base.js";
|
||||
import {
|
||||
chunkMarkdownIR,
|
||||
markdownToIR,
|
||||
type MarkdownIR,
|
||||
type MarkdownStyle,
|
||||
} from "../../../src/markdown/ir.js";
|
||||
|
||||
type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER";
|
||||
|
||||
export type SignalTextStyleRange = {
|
||||
start: number;
|
||||
length: number;
|
||||
style: SignalTextStyle;
|
||||
};
|
||||
|
||||
export type SignalFormattedText = {
|
||||
text: string;
|
||||
styles: SignalTextStyleRange[];
|
||||
};
|
||||
|
||||
type SignalMarkdownOptions = {
|
||||
tableMode?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
type SignalStyleSpan = {
|
||||
start: number;
|
||||
end: number;
|
||||
style: SignalTextStyle;
|
||||
};
|
||||
|
||||
type Insertion = {
|
||||
pos: number;
|
||||
length: number;
|
||||
};
|
||||
|
||||
function normalizeUrlForComparison(url: string): string {
|
||||
let normalized = url.toLowerCase();
|
||||
// Strip protocol
|
||||
normalized = normalized.replace(/^https?:\/\//, "");
|
||||
// Strip www. prefix
|
||||
normalized = normalized.replace(/^www\./, "");
|
||||
// Strip trailing slashes
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapStyle(style: MarkdownStyle): SignalTextStyle | null {
|
||||
switch (style) {
|
||||
case "bold":
|
||||
return "BOLD";
|
||||
case "italic":
|
||||
return "ITALIC";
|
||||
case "strikethrough":
|
||||
return "STRIKETHROUGH";
|
||||
case "code":
|
||||
case "code_block":
|
||||
return "MONOSPACE";
|
||||
case "spoiler":
|
||||
return "SPOILER";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeStyles(styles: SignalTextStyleRange[]): SignalTextStyleRange[] {
|
||||
const sorted = [...styles].toSorted((a, b) => {
|
||||
if (a.start !== b.start) {
|
||||
return a.start - b.start;
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return a.length - b.length;
|
||||
}
|
||||
return a.style.localeCompare(b.style);
|
||||
});
|
||||
|
||||
const merged: SignalTextStyleRange[] = [];
|
||||
for (const style of sorted) {
|
||||
const prev = merged[merged.length - 1];
|
||||
if (prev && prev.style === style.style && style.start <= prev.start + prev.length) {
|
||||
const prevEnd = prev.start + prev.length;
|
||||
const nextEnd = Math.max(prevEnd, style.start + style.length);
|
||||
prev.length = nextEnd - prev.start;
|
||||
continue;
|
||||
}
|
||||
merged.push({ ...style });
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function clampStyles(styles: SignalTextStyleRange[], maxLength: number): SignalTextStyleRange[] {
|
||||
const clamped: SignalTextStyleRange[] = [];
|
||||
for (const style of styles) {
|
||||
const start = Math.max(0, Math.min(style.start, maxLength));
|
||||
const end = Math.min(style.start + style.length, maxLength);
|
||||
const length = end - start;
|
||||
if (length > 0) {
|
||||
clamped.push({ start, length, style: style.style });
|
||||
}
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function applyInsertionsToStyles(
|
||||
spans: SignalStyleSpan[],
|
||||
insertions: Insertion[],
|
||||
): SignalStyleSpan[] {
|
||||
if (insertions.length === 0) {
|
||||
return spans;
|
||||
}
|
||||
const sortedInsertions = [...insertions].toSorted((a, b) => a.pos - b.pos);
|
||||
let updated = spans;
|
||||
let cumulativeShift = 0;
|
||||
|
||||
for (const insertion of sortedInsertions) {
|
||||
const insertionPos = insertion.pos + cumulativeShift;
|
||||
const next: SignalStyleSpan[] = [];
|
||||
for (const span of updated) {
|
||||
if (span.end <= insertionPos) {
|
||||
next.push(span);
|
||||
continue;
|
||||
}
|
||||
if (span.start >= insertionPos) {
|
||||
next.push({
|
||||
start: span.start + insertion.length,
|
||||
end: span.end + insertion.length,
|
||||
style: span.style,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (span.start < insertionPos && span.end > insertionPos) {
|
||||
if (insertionPos > span.start) {
|
||||
next.push({
|
||||
start: span.start,
|
||||
end: insertionPos,
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
const shiftedStart = insertionPos + insertion.length;
|
||||
const shiftedEnd = span.end + insertion.length;
|
||||
if (shiftedEnd > shiftedStart) {
|
||||
next.push({
|
||||
start: shiftedStart,
|
||||
end: shiftedEnd,
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
updated = next;
|
||||
cumulativeShift += insertion.length;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
function renderSignalText(ir: MarkdownIR): SignalFormattedText {
|
||||
const text = ir.text ?? "";
|
||||
if (!text) {
|
||||
return { text: "", styles: [] };
|
||||
}
|
||||
|
||||
const sortedLinks = [...ir.links].toSorted((a, b) => a.start - b.start);
|
||||
let out = "";
|
||||
let cursor = 0;
|
||||
const insertions: Insertion[] = [];
|
||||
|
||||
for (const link of sortedLinks) {
|
||||
if (link.start < cursor) {
|
||||
continue;
|
||||
}
|
||||
out += text.slice(cursor, link.end);
|
||||
|
||||
const href = link.href.trim();
|
||||
const label = text.slice(link.start, link.end);
|
||||
const trimmedLabel = label.trim();
|
||||
|
||||
if (href) {
|
||||
if (!trimmedLabel) {
|
||||
out += href;
|
||||
insertions.push({ pos: link.end, length: href.length });
|
||||
} else {
|
||||
// Check if label is similar enough to URL that showing both would be redundant
|
||||
const normalizedLabel = normalizeUrlForComparison(trimmedLabel);
|
||||
let comparableHref = href;
|
||||
if (href.startsWith("mailto:")) {
|
||||
comparableHref = href.slice("mailto:".length);
|
||||
}
|
||||
const normalizedHref = normalizeUrlForComparison(comparableHref);
|
||||
|
||||
// Only show URL if label is meaningfully different from it
|
||||
if (normalizedLabel !== normalizedHref) {
|
||||
const addition = ` (${href})`;
|
||||
out += addition;
|
||||
insertions.push({ pos: link.end, length: addition.length });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cursor = link.end;
|
||||
}
|
||||
|
||||
out += text.slice(cursor);
|
||||
|
||||
const mappedStyles: SignalStyleSpan[] = ir.styles
|
||||
.map((span) => {
|
||||
const mapped = mapStyle(span.style);
|
||||
if (!mapped) {
|
||||
return null;
|
||||
}
|
||||
return { start: span.start, end: span.end, style: mapped };
|
||||
})
|
||||
.filter((span): span is SignalStyleSpan => span !== null);
|
||||
|
||||
const adjusted = applyInsertionsToStyles(mappedStyles, insertions);
|
||||
const trimmedText = out.trimEnd();
|
||||
const trimmedLength = trimmedText.length;
|
||||
const clamped = clampStyles(
|
||||
adjusted.map((span) => ({
|
||||
start: span.start,
|
||||
length: span.end - span.start,
|
||||
style: span.style,
|
||||
})),
|
||||
trimmedLength,
|
||||
);
|
||||
|
||||
return {
|
||||
text: trimmedText,
|
||||
styles: mergeStyles(clamped),
|
||||
};
|
||||
}
|
||||
|
||||
export function markdownToSignalText(
|
||||
markdown: string,
|
||||
options: SignalMarkdownOptions = {},
|
||||
): SignalFormattedText {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
enableSpoilers: true,
|
||||
headingStyle: "bold",
|
||||
blockquotePrefix: "> ",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
return renderSignalText(ir);
|
||||
}
|
||||
|
||||
function sliceSignalStyles(
|
||||
styles: SignalTextStyleRange[],
|
||||
start: number,
|
||||
end: number,
|
||||
): SignalTextStyleRange[] {
|
||||
const sliced: SignalTextStyleRange[] = [];
|
||||
for (const style of styles) {
|
||||
const styleEnd = style.start + style.length;
|
||||
const sliceStart = Math.max(style.start, start);
|
||||
const sliceEnd = Math.min(styleEnd, end);
|
||||
if (sliceEnd > sliceStart) {
|
||||
sliced.push({
|
||||
start: sliceStart - start,
|
||||
length: sliceEnd - sliceStart,
|
||||
style: style.style,
|
||||
});
|
||||
}
|
||||
}
|
||||
return sliced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split Signal formatted text into chunks under the limit while preserving styles.
|
||||
*
|
||||
* This implementation deterministically tracks cursor position without using indexOf,
|
||||
* which is fragile when chunks are trimmed or when duplicate substrings exist.
|
||||
* Styles spanning chunk boundaries are split into separate ranges for each chunk.
|
||||
*/
|
||||
function splitSignalFormattedText(
|
||||
formatted: SignalFormattedText,
|
||||
limit: number,
|
||||
): SignalFormattedText[] {
|
||||
const { text, styles } = formatted;
|
||||
|
||||
if (text.length <= limit) {
|
||||
return [formatted];
|
||||
}
|
||||
|
||||
const results: SignalFormattedText[] = [];
|
||||
let remaining = text;
|
||||
let offset = 0; // Track position in original text for style slicing
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= limit) {
|
||||
// Last chunk - take everything remaining
|
||||
const trimmed = remaining.trimEnd();
|
||||
if (trimmed.length > 0) {
|
||||
results.push({
|
||||
text: trimmed,
|
||||
styles: mergeStyles(sliceSignalStyles(styles, offset, offset + trimmed.length)),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Find a good break point within the limit
|
||||
const window = remaining.slice(0, limit);
|
||||
let breakIdx = findBreakIndex(window);
|
||||
|
||||
// If no good break point found, hard break at limit
|
||||
if (breakIdx <= 0) {
|
||||
breakIdx = limit;
|
||||
}
|
||||
|
||||
// Extract chunk and trim trailing whitespace
|
||||
const rawChunk = remaining.slice(0, breakIdx);
|
||||
const chunk = rawChunk.trimEnd();
|
||||
|
||||
if (chunk.length > 0) {
|
||||
results.push({
|
||||
text: chunk,
|
||||
styles: mergeStyles(sliceSignalStyles(styles, offset, offset + chunk.length)),
|
||||
});
|
||||
}
|
||||
|
||||
// Advance past the chunk and any whitespace separator
|
||||
const brokeOnWhitespace = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnWhitespace ? 1 : 0));
|
||||
|
||||
// Chunks are sent as separate messages, so we intentionally drop boundary whitespace.
|
||||
// Keep `offset` in sync with the dropped characters so style slicing stays correct.
|
||||
remaining = remaining.slice(nextStart).trimStart();
|
||||
offset = text.length - remaining.length;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best break index within a text window.
|
||||
* Prefers newlines over whitespace, avoids breaking inside parentheses.
|
||||
*/
|
||||
function findBreakIndex(window: string): number {
|
||||
let lastNewline = -1;
|
||||
let lastWhitespace = -1;
|
||||
let parenDepth = 0;
|
||||
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
const char = window[i];
|
||||
|
||||
if (char === "(") {
|
||||
parenDepth++;
|
||||
continue;
|
||||
}
|
||||
if (char === ")" && parenDepth > 0) {
|
||||
parenDepth--;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only consider break points outside parentheses
|
||||
if (parenDepth === 0) {
|
||||
if (char === "\n") {
|
||||
lastNewline = i;
|
||||
} else if (/\s/.test(char)) {
|
||||
lastWhitespace = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer newline break, fall back to whitespace
|
||||
return lastNewline > 0 ? lastNewline : lastWhitespace;
|
||||
}
|
||||
|
||||
export function markdownToSignalTextChunks(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
options: SignalMarkdownOptions = {},
|
||||
): SignalFormattedText[] {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
enableSpoilers: true,
|
||||
headingStyle: "bold",
|
||||
blockquotePrefix: "> ",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
const chunks = chunkMarkdownIR(ir, limit);
|
||||
const results: SignalFormattedText[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const rendered = renderSignalText(chunk);
|
||||
// If link expansion caused the chunk to exceed the limit, re-chunk it
|
||||
if (rendered.text.length > limit) {
|
||||
results.push(...splitSignalFormattedText(rendered, limit));
|
||||
} else {
|
||||
results.push(rendered);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
57
extensions/signal/src/format.visual.test.ts
Normal file
57
extensions/signal/src/format.visual.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalText } from "./format.js";
|
||||
|
||||
describe("markdownToSignalText", () => {
|
||||
describe("headings visual distinction", () => {
|
||||
it("renders headings as bold text", () => {
|
||||
const res = markdownToSignalText("# Heading 1");
|
||||
expect(res.text).toBe("Heading 1");
|
||||
expect(res.styles).toContainEqual({ start: 0, length: 9, style: "BOLD" });
|
||||
});
|
||||
|
||||
it("renders h2 headings as bold text", () => {
|
||||
const res = markdownToSignalText("## Heading 2");
|
||||
expect(res.text).toBe("Heading 2");
|
||||
expect(res.styles).toContainEqual({ start: 0, length: 9, style: "BOLD" });
|
||||
});
|
||||
|
||||
it("renders h3 headings as bold text", () => {
|
||||
const res = markdownToSignalText("### Heading 3");
|
||||
expect(res.text).toBe("Heading 3");
|
||||
expect(res.styles).toContainEqual({ start: 0, length: 9, style: "BOLD" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("blockquote visual distinction", () => {
|
||||
it("renders blockquotes with a visible prefix", () => {
|
||||
const res = markdownToSignalText("> This is a quote");
|
||||
// Should have some kind of prefix to distinguish it
|
||||
expect(res.text).toMatch(/^[│>]/);
|
||||
expect(res.text).toContain("This is a quote");
|
||||
});
|
||||
|
||||
it("renders multi-line blockquotes with prefix", () => {
|
||||
const res = markdownToSignalText("> Line 1\n> Line 2");
|
||||
// Should start with the prefix
|
||||
expect(res.text).toMatch(/^[│>]/);
|
||||
expect(res.text).toContain("Line 1");
|
||||
expect(res.text).toContain("Line 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("horizontal rule rendering", () => {
|
||||
it("renders horizontal rules as a visible separator", () => {
|
||||
const res = markdownToSignalText("Para 1\n\n---\n\nPara 2");
|
||||
// Should contain some kind of visual separator like ───
|
||||
expect(res.text).toMatch(/[─—-]{3,}/);
|
||||
});
|
||||
|
||||
it("renders horizontal rule between content", () => {
|
||||
const res = markdownToSignalText("Above\n\n***\n\nBelow");
|
||||
expect(res.text).toContain("Above");
|
||||
expect(res.text).toContain("Below");
|
||||
// Should have a separator
|
||||
expect(res.text).toMatch(/[─—-]{3,}/);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
extensions/signal/src/identity.test.ts
Normal file
56
extensions/signal/src/identity.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
looksLikeUuid,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
} from "./identity.js";
|
||||
|
||||
describe("looksLikeUuid", () => {
|
||||
it("accepts hyphenated UUIDs", () => {
|
||||
expect(looksLikeUuid("123e4567-e89b-12d3-a456-426614174000")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts compact UUIDs", () => {
|
||||
expect(looksLikeUuid("123e4567e89b12d3a456426614174000")).toBe(true); // pragma: allowlist secret
|
||||
});
|
||||
|
||||
it("accepts uuid-like hex values with letters", () => {
|
||||
expect(looksLikeUuid("abcd-1234")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects numeric ids and phone-like values", () => {
|
||||
expect(looksLikeUuid("1234567890")).toBe(false);
|
||||
expect(looksLikeUuid("+15555551212")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signal sender identity", () => {
|
||||
it("prefers sourceNumber over sourceUuid", () => {
|
||||
const sender = resolveSignalSender({
|
||||
sourceNumber: " +15550001111 ",
|
||||
sourceUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
expect(sender).toEqual({
|
||||
kind: "phone",
|
||||
raw: "+15550001111",
|
||||
e164: "+15550001111",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses sourceUuid when sourceNumber is missing", () => {
|
||||
const sender = resolveSignalSender({
|
||||
sourceUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
expect(sender).toEqual({
|
||||
kind: "uuid",
|
||||
raw: "123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps uuid senders to recipient and peer ids", () => {
|
||||
const sender = { kind: "uuid", raw: "123e4567-e89b-12d3-a456-426614174000" } as const;
|
||||
expect(resolveSignalRecipient(sender)).toBe("123e4567-e89b-12d3-a456-426614174000");
|
||||
expect(resolveSignalPeerId(sender)).toBe("uuid:123e4567-e89b-12d3-a456-426614174000");
|
||||
});
|
||||
});
|
||||
139
extensions/signal/src/identity.ts
Normal file
139
extensions/signal/src/identity.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
|
||||
import { normalizeE164 } from "../../../src/utils.js";
|
||||
|
||||
export type SignalSender =
|
||||
| { kind: "phone"; raw: string; e164: string }
|
||||
| { kind: "uuid"; raw: string };
|
||||
|
||||
type SignalAllowEntry =
|
||||
| { kind: "any" }
|
||||
| { kind: "phone"; e164: string }
|
||||
| { kind: "uuid"; raw: string };
|
||||
|
||||
const UUID_HYPHENATED_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_COMPACT_RE = /^[0-9a-f]{32}$/i;
|
||||
|
||||
export function looksLikeUuid(value: string): boolean {
|
||||
if (UUID_HYPHENATED_RE.test(value) || UUID_COMPACT_RE.test(value)) {
|
||||
return true;
|
||||
}
|
||||
const compact = value.replace(/-/g, "");
|
||||
if (!/^[0-9a-f]+$/i.test(compact)) {
|
||||
return false;
|
||||
}
|
||||
return /[a-f]/i.test(compact);
|
||||
}
|
||||
|
||||
function stripSignalPrefix(value: string): string {
|
||||
return value.replace(/^signal:/i, "").trim();
|
||||
}
|
||||
|
||||
export function resolveSignalSender(params: {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
}): SignalSender | null {
|
||||
const sourceNumber = params.sourceNumber?.trim();
|
||||
if (sourceNumber) {
|
||||
return {
|
||||
kind: "phone",
|
||||
raw: sourceNumber,
|
||||
e164: normalizeE164(sourceNumber),
|
||||
};
|
||||
}
|
||||
const sourceUuid = params.sourceUuid?.trim();
|
||||
if (sourceUuid) {
|
||||
return { kind: "uuid", raw: sourceUuid };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatSignalSenderId(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
export function formatSignalSenderDisplay(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
export function formatSignalPairingIdLine(sender: SignalSender): string {
|
||||
if (sender.kind === "phone") {
|
||||
return `Your Signal number: ${sender.e164}`;
|
||||
}
|
||||
return `Your Signal sender id: ${formatSignalSenderId(sender)}`;
|
||||
}
|
||||
|
||||
export function resolveSignalRecipient(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : sender.raw;
|
||||
}
|
||||
|
||||
export function resolveSignalPeerId(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
function parseSignalAllowEntry(entry: string): SignalAllowEntry | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return { kind: "any" };
|
||||
}
|
||||
|
||||
const stripped = stripSignalPrefix(trimmed);
|
||||
const lower = stripped.toLowerCase();
|
||||
if (lower.startsWith("uuid:")) {
|
||||
const raw = stripped.slice("uuid:".length).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "uuid", raw };
|
||||
}
|
||||
|
||||
if (looksLikeUuid(stripped)) {
|
||||
return { kind: "uuid", raw: stripped };
|
||||
}
|
||||
|
||||
return { kind: "phone", e164: normalizeE164(stripped) };
|
||||
}
|
||||
|
||||
export function normalizeSignalAllowRecipient(entry: string): string | undefined {
|
||||
const parsed = parseSignalAllowEntry(entry);
|
||||
if (!parsed || parsed.kind === "any") {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.kind === "phone" ? parsed.e164 : parsed.raw;
|
||||
}
|
||||
|
||||
export function isSignalSenderAllowed(sender: SignalSender, allowFrom: string[]): boolean {
|
||||
if (allowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const parsed = allowFrom
|
||||
.map(parseSignalAllowEntry)
|
||||
.filter((entry): entry is SignalAllowEntry => entry !== null);
|
||||
if (parsed.some((entry) => entry.kind === "any")) {
|
||||
return true;
|
||||
}
|
||||
return parsed.some((entry) => {
|
||||
if (entry.kind === "phone" && sender.kind === "phone") {
|
||||
return entry.e164 === sender.e164;
|
||||
}
|
||||
if (entry.kind === "uuid" && sender.kind === "uuid") {
|
||||
return entry.raw === sender.raw;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function isSignalGroupAllowed(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
allowFrom: string[];
|
||||
sender: SignalSender;
|
||||
}): boolean {
|
||||
return evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy: params.groupPolicy,
|
||||
groupAllowFrom: params.allowFrom,
|
||||
senderId: params.sender.raw,
|
||||
isSenderAllowed: () => isSignalSenderAllowed(params.sender, params.allowFrom),
|
||||
}).allowed;
|
||||
}
|
||||
5
extensions/signal/src/index.ts
Normal file
5
extensions/signal/src/index.ts
Normal file
@@ -0,0 +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";
|
||||
67
extensions/signal/src/monitor.test.ts
Normal file
67
extensions/signal/src/monitor.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSignalGroupAllowed } from "./identity.js";
|
||||
|
||||
describe("signal groupPolicy gating", () => {
|
||||
it("allows when policy is open", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks when policy is disabled", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["+15550001111"],
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks allowlist when empty", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows allowlist when sender matches", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist wildcard", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["*"],
|
||||
sender: { kind: "phone", raw: "+15550002222", e164: "+15550002222" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist when uuid sender matches", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["uuid:123e4567-e89b-12d3-a456-426614174000"],
|
||||
sender: {
|
||||
kind: "uuid",
|
||||
raw: "123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
config,
|
||||
flush,
|
||||
getSignalToolResultTestMocks,
|
||||
installSignalToolResultTestHooks,
|
||||
setSignalToolResultTestConfig,
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
|
||||
installSignalToolResultTestHooks();
|
||||
|
||||
// Import after the harness registers `vi.mock(...)` for Signal internals.
|
||||
const { monitorSignalProvider } = await import("./monitor.js");
|
||||
|
||||
const { replyMock, sendMock, streamMock, upsertPairingRequestMock } =
|
||||
getSignalToolResultTestMocks();
|
||||
|
||||
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
|
||||
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider(opts);
|
||||
}
|
||||
describe("monitorSignalProvider tool results", () => {
|
||||
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
|
||||
const baseChannels = (config.channels ?? {}) as Record<string, unknown>;
|
||||
const baseSignal = (baseChannels.signal ?? {}) as Record<string, unknown>;
|
||||
setSignalToolResultTestConfig({
|
||||
...config,
|
||||
channels: {
|
||||
...baseChannels,
|
||||
signal: {
|
||||
...baseSignal,
|
||||
autoStart: false,
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const uuid = "123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
const payload = {
|
||||
envelope: {
|
||||
sourceUuid: uuid,
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
};
|
||||
await onEvent({
|
||||
event: "receive",
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "signal",
|
||||
id: `uuid:${uuid}`,
|
||||
meta: expect.objectContaining({ name: "Ada" }),
|
||||
}),
|
||||
);
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0]?.[0]).toBe(`signal:${uuid}`);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
`Your Signal sender id: uuid:${uuid}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("reconnects after stream errors until aborted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const abortController = new AbortController();
|
||||
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
let calls = 0;
|
||||
|
||||
streamMock.mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("stream dropped");
|
||||
}
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
try {
|
||||
const monitorPromise = monitorSignalProvider({
|
||||
autoStart: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
reconnectPolicy: {
|
||||
initialMs: 1,
|
||||
maxMs: 1,
|
||||
factor: 1,
|
||||
jitter: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await monitorPromise;
|
||||
|
||||
expect(streamMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
randomSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { peekSystemEvents } from "../../../src/infra/system-events.js";
|
||||
import { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||
import { normalizeE164 } from "../../../src/utils.js";
|
||||
import type { SignalDaemonExitEvent } from "./daemon.js";
|
||||
import {
|
||||
createMockSignalDaemonHandle,
|
||||
config,
|
||||
flush,
|
||||
getSignalToolResultTestMocks,
|
||||
installSignalToolResultTestHooks,
|
||||
setSignalToolResultTestConfig,
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
|
||||
installSignalToolResultTestHooks();
|
||||
|
||||
// Import after the harness registers `vi.mock(...)` for Signal internals.
|
||||
const { monitorSignalProvider } = await import("./monitor.js");
|
||||
|
||||
const {
|
||||
replyMock,
|
||||
sendMock,
|
||||
streamMock,
|
||||
updateLastRouteMock,
|
||||
upsertPairingRequestMock,
|
||||
waitForTransportReadyMock,
|
||||
spawnSignalDaemonMock,
|
||||
} = getSignalToolResultTestMocks();
|
||||
|
||||
const SIGNAL_BASE_URL = "http://127.0.0.1:8080";
|
||||
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
|
||||
|
||||
function createMonitorRuntime() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
}
|
||||
|
||||
function setSignalAutoStartConfig(overrides: Record<string, unknown> = {}) {
|
||||
setSignalToolResultTestConfig(createSignalConfig(overrides));
|
||||
}
|
||||
|
||||
function createSignalConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const base = config as OpenClawConfig;
|
||||
const channels = (base.channels ?? {}) as Record<string, unknown>;
|
||||
const signal = (channels.signal ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...base,
|
||||
channels: {
|
||||
...channels,
|
||||
signal: {
|
||||
...signal,
|
||||
autoStart: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAutoAbortController() {
|
||||
const abortController = new AbortController();
|
||||
streamMock.mockImplementation(async () => {
|
||||
abortController.abort();
|
||||
return;
|
||||
});
|
||||
return abortController;
|
||||
}
|
||||
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider(opts);
|
||||
}
|
||||
|
||||
async function receiveSignalPayloads(params: {
|
||||
payloads: unknown[];
|
||||
opts?: Partial<MonitorSignalProviderOptions>;
|
||||
}) {
|
||||
const abortController = new AbortController();
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
for (const payload of params.payloads) {
|
||||
await onEvent({
|
||||
event: "receive",
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: false,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
...params.opts,
|
||||
});
|
||||
|
||||
await flush();
|
||||
}
|
||||
|
||||
function getDirectSignalEventsFor(sender: string) {
|
||||
const route = resolveAgentRoute({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: "signal",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: normalizeE164(sender) },
|
||||
});
|
||||
return peekSystemEvents(route.sessionKey);
|
||||
}
|
||||
|
||||
function makeBaseEnvelope(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function receiveSingleEnvelope(
|
||||
envelope: Record<string, unknown>,
|
||||
opts?: Partial<MonitorSignalProviderOptions>,
|
||||
) {
|
||||
await receiveSignalPayloads({
|
||||
payloads: [{ envelope }],
|
||||
opts,
|
||||
});
|
||||
}
|
||||
|
||||
function expectNoReplyDeliveryOrRouteUpdate() {
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(updateLastRouteMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function setReactionNotificationConfig(mode: "all" | "own", extra: Record<string, unknown> = {}) {
|
||||
setSignalToolResultTestConfig(
|
||||
createSignalConfig({
|
||||
autoStart: false,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
reactionNotifications: mode,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectWaitForTransportReadyTimeout(timeoutMs: number) {
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("monitorSignalProvider tool results", () => {
|
||||
it("uses bounded readiness checks when auto-starting the daemon", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig();
|
||||
const abortController = createAutoAbortController();
|
||||
await runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: "signal daemon",
|
||||
timeoutMs: 30_000,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
pollIntervalMs: 150,
|
||||
runtime,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses startupTimeoutMs override when provided", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig({ startupTimeoutMs: 60_000 });
|
||||
const abortController = createAutoAbortController();
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
startupTimeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expectWaitForTransportReadyTimeout(90_000);
|
||||
});
|
||||
|
||||
it("caps startupTimeoutMs at 2 minutes", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig({ startupTimeoutMs: 180_000 });
|
||||
const abortController = createAutoAbortController();
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expectWaitForTransportReadyTimeout(120_000);
|
||||
});
|
||||
|
||||
it("fails fast when auto-started signal daemon exits during startup", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig();
|
||||
spawnSignalDaemonMock.mockReturnValueOnce(
|
||||
createMockSignalDaemonHandle({
|
||||
exited: Promise.resolve({ source: "process", code: 1, signal: null }),
|
||||
isExited: () => true,
|
||||
}),
|
||||
);
|
||||
waitForTransportReadyMock.mockImplementationOnce(
|
||||
async (params: { abortSignal?: AbortSignal | null }) => {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (params.abortSignal?.aborted) {
|
||||
reject(params.abortSignal.reason);
|
||||
return;
|
||||
}
|
||||
params.abortSignal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(params.abortSignal?.reason ?? new Error("aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
runtime,
|
||||
}),
|
||||
).rejects.toThrow(/signal daemon exited/i);
|
||||
});
|
||||
|
||||
it("treats daemon exit after user abort as clean shutdown", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig();
|
||||
const abortController = new AbortController();
|
||||
let exited = false;
|
||||
let resolveExit!: (value: SignalDaemonExitEvent) => void;
|
||||
const exitedPromise = new Promise<SignalDaemonExitEvent>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const stop = vi.fn(() => {
|
||||
if (exited) {
|
||||
return;
|
||||
}
|
||||
exited = true;
|
||||
resolveExit({ source: "process", code: null, signal: "SIGTERM" });
|
||||
});
|
||||
spawnSignalDaemonMock.mockReturnValueOnce(
|
||||
createMockSignalDaemonHandle({
|
||||
stop,
|
||||
exited: exitedPromise,
|
||||
isExited: () => exited,
|
||||
}),
|
||||
);
|
||||
streamMock.mockImplementationOnce(async () => {
|
||||
abortController.abort(new Error("stop"));
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
runtime,
|
||||
abortSignal: abortController.signal,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips tool summaries with responsePrefix", async () => {
|
||||
replyMock.mockResolvedValue({ text: "final reply" });
|
||||
|
||||
await receiveSignalPayloads({
|
||||
payloads: [
|
||||
{
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
|
||||
setSignalToolResultTestConfig(
|
||||
createSignalConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),
|
||||
);
|
||||
await receiveSignalPayloads({
|
||||
payloads: [
|
||||
{
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Your Signal number: +15550001111");
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Pairing code: PAIRCODE");
|
||||
});
|
||||
|
||||
it("ignores reaction-only messages", async () => {
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
reactionMessage: {
|
||||
emoji: "👍",
|
||||
targetAuthor: "+15550002222",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expectNoReplyDeliveryOrRouteUpdate();
|
||||
});
|
||||
|
||||
it("ignores reaction-only dataMessage.reaction events (don’t treat as broken attachments)", async () => {
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
dataMessage: {
|
||||
reaction: {
|
||||
emoji: "👍",
|
||||
targetAuthor: "+15550002222",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
attachments: [{}],
|
||||
},
|
||||
});
|
||||
|
||||
expectNoReplyDeliveryOrRouteUpdate();
|
||||
});
|
||||
|
||||
it("enqueues system events for reaction notifications", async () => {
|
||||
setReactionNotificationConfig("all");
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
reactionMessage: {
|
||||
emoji: "✅",
|
||||
targetAuthor: "+15550002222",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const events = getDirectSignalEventsFor("+15550001111");
|
||||
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks reaction notifications from unauthorized senders when dmPolicy is allowlist",
|
||||
mode: "all" as const,
|
||||
extra: { dmPolicy: "allowlist", allowFrom: ["+15550007777"] } as Record<string, unknown>,
|
||||
targetAuthor: "+15550002222",
|
||||
shouldEnqueue: false,
|
||||
},
|
||||
{
|
||||
name: "blocks reaction notifications from unauthorized senders when dmPolicy is pairing",
|
||||
mode: "own" as const,
|
||||
extra: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
account: "+15550009999",
|
||||
} as Record<string, unknown>,
|
||||
targetAuthor: "+15550009999",
|
||||
shouldEnqueue: false,
|
||||
},
|
||||
{
|
||||
name: "allows reaction notifications for allowlisted senders when dmPolicy is allowlist",
|
||||
mode: "all" as const,
|
||||
extra: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } as Record<string, unknown>,
|
||||
targetAuthor: "+15550002222",
|
||||
shouldEnqueue: true,
|
||||
},
|
||||
])("$name", async ({ mode, extra, targetAuthor, shouldEnqueue }) => {
|
||||
setReactionNotificationConfig(mode, extra);
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
reactionMessage: {
|
||||
emoji: "✅",
|
||||
targetAuthor,
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const events = getDirectSignalEventsFor("+15550001111");
|
||||
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(shouldEnqueue);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies on own reactions when target includes uuid + phone", async () => {
|
||||
setReactionNotificationConfig("own", { account: "+15550002222" });
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
reactionMessage: {
|
||||
emoji: "✅",
|
||||
targetAuthor: "+15550002222",
|
||||
targetAuthorUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const events = getDirectSignalEventsFor("+15550001111");
|
||||
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
|
||||
});
|
||||
|
||||
it("processes messages when reaction metadata is present", async () => {
|
||||
replyMock.mockResolvedValue({ text: "pong" });
|
||||
|
||||
await receiveSignalPayloads({
|
||||
payloads: [
|
||||
{
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
reactionMessage: {
|
||||
emoji: "👍",
|
||||
targetAuthor: "+15550002222",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
dataMessage: {
|
||||
message: "ping",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateLastRouteMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resend pairing code when a request is already pending", async () => {
|
||||
setSignalToolResultTestConfig(
|
||||
createSignalConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),
|
||||
);
|
||||
upsertPairingRequestMock
|
||||
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
|
||||
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
|
||||
|
||||
const payload = {
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
};
|
||||
await receiveSignalPayloads({
|
||||
payloads: [
|
||||
payload,
|
||||
{
|
||||
...payload,
|
||||
envelope: { ...payload.envelope, timestamp: 2 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
146
extensions/signal/src/monitor.tool-result.test-harness.ts
Normal file
146
extensions/signal/src/monitor.tool-result.test-harness.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, vi } from "vitest";
|
||||
import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetSystemEventsForTest } from "../../../src/infra/system-events.js";
|
||||
import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js";
|
||||
import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js";
|
||||
|
||||
type SignalToolResultTestMocks = {
|
||||
waitForTransportReadyMock: MockFn;
|
||||
sendMock: MockFn;
|
||||
replyMock: MockFn;
|
||||
updateLastRouteMock: MockFn;
|
||||
readAllowFromStoreMock: MockFn;
|
||||
upsertPairingRequestMock: MockFn;
|
||||
streamMock: MockFn;
|
||||
signalCheckMock: MockFn;
|
||||
signalRpcRequestMock: MockFn;
|
||||
spawnSignalDaemonMock: MockFn;
|
||||
};
|
||||
|
||||
const waitForTransportReadyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const sendMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const replyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const updateLastRouteMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const readAllowFromStoreMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const upsertPairingRequestMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const streamMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const signalCheckMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const signalRpcRequestMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const spawnSignalDaemonMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
|
||||
export function getSignalToolResultTestMocks(): SignalToolResultTestMocks {
|
||||
return {
|
||||
waitForTransportReadyMock,
|
||||
sendMock,
|
||||
replyMock,
|
||||
updateLastRouteMock,
|
||||
readAllowFromStoreMock,
|
||||
upsertPairingRequestMock,
|
||||
streamMock,
|
||||
signalCheckMock,
|
||||
signalRpcRequestMock,
|
||||
spawnSignalDaemonMock,
|
||||
};
|
||||
}
|
||||
|
||||
export let config: Record<string, unknown> = {};
|
||||
|
||||
export function setSignalToolResultTestConfig(next: Record<string, unknown>) {
|
||||
config = next;
|
||||
}
|
||||
|
||||
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
export function createMockSignalDaemonHandle(
|
||||
overrides: {
|
||||
stop?: MockFn;
|
||||
exited?: Promise<SignalDaemonExitEvent>;
|
||||
isExited?: () => boolean;
|
||||
} = {},
|
||||
): SignalDaemonHandle {
|
||||
const stop = overrides.stop ?? (vi.fn() as unknown as MockFn);
|
||||
const exited = overrides.exited ?? new Promise<SignalDaemonExitEvent>(() => {});
|
||||
const isExited = overrides.isExited ?? (() => false);
|
||||
return {
|
||||
stop: stop as unknown as () => void,
|
||||
exited,
|
||||
isExited,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
||||
sendTypingSignal: vi.fn().mockResolvedValue(true),
|
||||
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
streamSignalEvents: (...args: unknown[]) => streamMock(...args),
|
||||
signalCheck: (...args: unknown[]) => signalCheckMock(...args),
|
||||
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./daemon.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./daemon.js")>();
|
||||
return {
|
||||
...actual,
|
||||
spawnSignalDaemon: (...args: unknown[]) => spawnSignalDaemonMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/infra/transport-ready.js", () => ({
|
||||
waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args),
|
||||
}));
|
||||
|
||||
export function installSignalToolResultTestHooks() {
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
channels: {
|
||||
signal: { autoStart: false, dmPolicy: "open", allowFrom: ["*"] },
|
||||
},
|
||||
};
|
||||
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
streamMock.mockReset();
|
||||
signalCheckMock.mockReset().mockResolvedValue({});
|
||||
signalRpcRequestMock.mockReset().mockResolvedValue({});
|
||||
spawnSignalDaemonMock.mockReset().mockReturnValue(createMockSignalDaemonHandle());
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
resetSystemEventsForTest();
|
||||
});
|
||||
}
|
||||
484
extensions/signal/src/monitor.ts
Normal file
484
extensions/signal/src/monitor.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
chunkTextWithMode,
|
||||
resolveChunkMode,
|
||||
resolveTextChunkLimit,
|
||||
} from "../../../src/auto-reply/chunk.js";
|
||||
import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type HistoryEntry,
|
||||
} from "../../../src/auto-reply/reply/history.js";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../../../src/config/runtime-group-policy.js";
|
||||
import type { SignalReactionNotificationMode } from "../../../src/config/types.js";
|
||||
import type { BackoffPolicy } from "../../../src/infra/backoff.js";
|
||||
import { waitForTransportReady } from "../../../src/infra/transport-ready.js";
|
||||
import { saveMediaBuffer } from "../../../src/media/store.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { normalizeStringEntries } from "../../../src/shared/string-normalization.js";
|
||||
import { normalizeE164 } from "../../../src/utils.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
|
||||
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
|
||||
import { createSignalEventHandler } from "./monitor/event-handler.js";
|
||||
import type {
|
||||
SignalAttachment,
|
||||
SignalReactionMessage,
|
||||
SignalReactionTarget,
|
||||
} from "./monitor/event-handler.types.js";
|
||||
import { sendMessageSignal } from "./send.js";
|
||||
import { runSignalSseLoop } from "./sse-reconnect.js";
|
||||
|
||||
export type MonitorSignalOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
config?: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
autoStart?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
cliPath?: string;
|
||||
httpHost?: string;
|
||||
httpPort?: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
reconnectPolicy?: Partial<BackoffPolicy>;
|
||||
};
|
||||
|
||||
function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
return opts.runtime ?? createNonExitingRuntime();
|
||||
}
|
||||
|
||||
function mergeAbortSignals(
|
||||
a?: AbortSignal,
|
||||
b?: AbortSignal,
|
||||
): { signal?: AbortSignal; dispose: () => void } {
|
||||
if (!a && !b) {
|
||||
return { signal: undefined, dispose: () => {} };
|
||||
}
|
||||
if (!a) {
|
||||
return { signal: b, dispose: () => {} };
|
||||
}
|
||||
if (!b) {
|
||||
return { signal: a, dispose: () => {} };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const abortFrom = (source: AbortSignal) => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(source.reason);
|
||||
}
|
||||
};
|
||||
if (a.aborted) {
|
||||
abortFrom(a);
|
||||
return { signal: controller.signal, dispose: () => {} };
|
||||
}
|
||||
if (b.aborted) {
|
||||
abortFrom(b);
|
||||
return { signal: controller.signal, dispose: () => {} };
|
||||
}
|
||||
const onAbortA = () => abortFrom(a);
|
||||
const onAbortB = () => abortFrom(b);
|
||||
a.addEventListener("abort", onAbortA, { once: true });
|
||||
b.addEventListener("abort", onAbortB, { once: true });
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose: () => {
|
||||
a.removeEventListener("abort", onAbortA);
|
||||
b.removeEventListener("abort", onAbortB);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSignalDaemonLifecycle(params: { abortSignal?: AbortSignal }) {
|
||||
let daemonHandle: SignalDaemonHandle | null = null;
|
||||
let daemonStopRequested = false;
|
||||
let daemonExitError: Error | undefined;
|
||||
const daemonAbortController = new AbortController();
|
||||
const mergedAbort = mergeAbortSignals(params.abortSignal, daemonAbortController.signal);
|
||||
const stop = () => {
|
||||
daemonStopRequested = true;
|
||||
daemonHandle?.stop();
|
||||
};
|
||||
const attach = (handle: SignalDaemonHandle) => {
|
||||
daemonHandle = handle;
|
||||
void handle.exited.then((exit) => {
|
||||
if (daemonStopRequested || params.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
daemonExitError = new Error(formatSignalDaemonExit(exit));
|
||||
if (!daemonAbortController.signal.aborted) {
|
||||
daemonAbortController.abort(daemonExitError);
|
||||
}
|
||||
});
|
||||
};
|
||||
const getExitError = () => daemonExitError;
|
||||
return {
|
||||
attach,
|
||||
stop,
|
||||
getExitError,
|
||||
abortSignal: mergedAbort.signal,
|
||||
dispose: mergedAbort.dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||
return normalizeStringEntries(raw);
|
||||
}
|
||||
|
||||
function resolveSignalReactionTargets(reaction: SignalReactionMessage): SignalReactionTarget[] {
|
||||
const targets: SignalReactionTarget[] = [];
|
||||
const uuid = reaction.targetAuthorUuid?.trim();
|
||||
if (uuid) {
|
||||
targets.push({ kind: "uuid", id: uuid, display: `uuid:${uuid}` });
|
||||
}
|
||||
const author = reaction.targetAuthor?.trim();
|
||||
if (author) {
|
||||
const normalized = normalizeE164(author);
|
||||
targets.push({ kind: "phone", id: normalized, display: normalized });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function isSignalReactionMessage(
|
||||
reaction: SignalReactionMessage | null | undefined,
|
||||
): reaction is SignalReactionMessage {
|
||||
if (!reaction) {
|
||||
return false;
|
||||
}
|
||||
const emoji = reaction.emoji?.trim();
|
||||
const timestamp = reaction.targetSentTimestamp;
|
||||
const hasTarget = Boolean(reaction.targetAuthor?.trim() || reaction.targetAuthorUuid?.trim());
|
||||
return Boolean(emoji && typeof timestamp === "number" && timestamp > 0 && hasTarget);
|
||||
}
|
||||
|
||||
function shouldEmitSignalReactionNotification(params: {
|
||||
mode?: SignalReactionNotificationMode;
|
||||
account?: string | null;
|
||||
targets?: SignalReactionTarget[];
|
||||
sender?: ReturnType<typeof resolveSignalSender> | null;
|
||||
allowlist?: string[];
|
||||
}) {
|
||||
const { mode, account, targets, sender, allowlist } = params;
|
||||
const effectiveMode = mode ?? "own";
|
||||
if (effectiveMode === "off") {
|
||||
return false;
|
||||
}
|
||||
if (effectiveMode === "own") {
|
||||
const accountId = account?.trim();
|
||||
if (!accountId || !targets || targets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalizedAccount = normalizeE164(accountId);
|
||||
return targets.some((target) => {
|
||||
if (target.kind === "uuid") {
|
||||
return accountId === target.id || accountId === `uuid:${target.id}`;
|
||||
}
|
||||
return normalizedAccount === target.id;
|
||||
});
|
||||
}
|
||||
if (effectiveMode === "allowlist") {
|
||||
if (!sender || !allowlist || allowlist.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return isSignalSenderAllowed(sender, allowlist);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildSignalReactionSystemEventText(params: {
|
||||
emojiLabel: string;
|
||||
actorLabel: string;
|
||||
messageId: string;
|
||||
targetLabel?: string;
|
||||
groupLabel?: string;
|
||||
}) {
|
||||
const base = `Signal reaction added: ${params.emojiLabel} by ${params.actorLabel} msg ${params.messageId}`;
|
||||
const withTarget = params.targetLabel ? `${base} from ${params.targetLabel}` : base;
|
||||
return params.groupLabel ? `${withTarget} in ${params.groupLabel}` : withTarget;
|
||||
}
|
||||
|
||||
async function waitForSignalDaemonReady(params: {
|
||||
baseUrl: string;
|
||||
abortSignal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
logAfterMs: number;
|
||||
logIntervalMs?: number;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<void> {
|
||||
await waitForTransportReady({
|
||||
label: "signal daemon",
|
||||
timeoutMs: params.timeoutMs,
|
||||
logAfterMs: params.logAfterMs,
|
||||
logIntervalMs: params.logIntervalMs,
|
||||
pollIntervalMs: 150,
|
||||
abortSignal: params.abortSignal,
|
||||
runtime: params.runtime,
|
||||
check: async () => {
|
||||
const res = await signalCheck(params.baseUrl, 1000);
|
||||
if (res.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAttachment(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
attachment: SignalAttachment;
|
||||
sender?: string;
|
||||
groupId?: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{ path: string; contentType?: string } | null> {
|
||||
const { attachment } = params;
|
||||
if (!attachment?.id) {
|
||||
return null;
|
||||
}
|
||||
if (attachment.size && attachment.size > params.maxBytes) {
|
||||
throw new Error(
|
||||
`Signal attachment ${attachment.id} exceeds ${(params.maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
|
||||
);
|
||||
}
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
id: attachment.id,
|
||||
};
|
||||
if (params.account) {
|
||||
rpcParams.account = params.account;
|
||||
}
|
||||
if (params.groupId) {
|
||||
rpcParams.groupId = params.groupId;
|
||||
} else if (params.sender) {
|
||||
rpcParams.recipient = params.sender;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await signalRpcRequest<{ data?: string }>("getAttachment", rpcParams, {
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
if (!result?.data) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(result.data, "base64");
|
||||
const saved = await saveMediaBuffer(
|
||||
buffer,
|
||||
attachment.contentType ?? undefined,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
chunkMode: "length" | "newline";
|
||||
}) {
|
||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
||||
params;
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (!text && mediaList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||
await sendMessageSignal(target, chunk, {
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await sendMessageSignal(target, caption, {
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl: url,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
accountInfo.config.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
||||
const chunkMode = resolveChunkMode(cfg, "signal", accountInfo.accountId);
|
||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? accountInfo.config.allowFrom);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
accountInfo.config.groupAllowFrom ??
|
||||
(accountInfo.config.allowFrom && accountInfo.config.allowFrom.length > 0
|
||||
? accountInfo.config.allowFrom
|
||||
: []),
|
||||
);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.signal !== undefined,
|
||||
groupPolicy: accountInfo.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
log: (message) => runtime.log?.(message),
|
||||
});
|
||||
const reactionMode = accountInfo.config.reactionNotifications ?? "own";
|
||||
const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist);
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||
|
||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||
const startupTimeoutMs = Math.min(
|
||||
120_000,
|
||||
Math.max(1_000, opts.startupTimeoutMs ?? accountInfo.config.startupTimeoutMs ?? 30_000),
|
||||
);
|
||||
const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts);
|
||||
const daemonLifecycle = createSignalDaemonLifecycle({ abortSignal: opts.abortSignal });
|
||||
let daemonHandle: SignalDaemonHandle | null = null;
|
||||
|
||||
if (autoStart) {
|
||||
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
|
||||
const httpHost = opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
|
||||
daemonHandle = spawnSignalDaemon({
|
||||
cliPath,
|
||||
account,
|
||||
httpHost,
|
||||
httpPort,
|
||||
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
|
||||
ignoreAttachments: opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
|
||||
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
|
||||
sendReadReceipts,
|
||||
runtime,
|
||||
});
|
||||
daemonLifecycle.attach(daemonHandle);
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
daemonLifecycle.stop();
|
||||
};
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
if (daemonHandle) {
|
||||
await waitForSignalDaemonReady({
|
||||
baseUrl,
|
||||
abortSignal: daemonLifecycle.abortSignal,
|
||||
timeoutMs: startupTimeoutMs,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
runtime,
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
}
|
||||
|
||||
const handleEvent = createSignalEventHandler({
|
||||
runtime,
|
||||
cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
accountUuid: accountInfo.config.accountUuid,
|
||||
accountId: accountInfo.accountId,
|
||||
blockStreaming: accountInfo.config.blockStreaming,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
textLimit,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
groupPolicy,
|
||||
reactionMode,
|
||||
reactionAllowlist,
|
||||
mediaMaxBytes,
|
||||
ignoreAttachments,
|
||||
sendReadReceipts,
|
||||
readReceiptsViaDaemon,
|
||||
fetchAttachment,
|
||||
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }),
|
||||
resolveSignalReactionTargets,
|
||||
isSignalReactionMessage,
|
||||
shouldEmitSignalReactionNotification,
|
||||
buildSignalReactionSystemEventText,
|
||||
});
|
||||
|
||||
await runSignalSseLoop({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal: daemonLifecycle.abortSignal,
|
||||
runtime,
|
||||
policy: opts.reconnectPolicy,
|
||||
onEvent: (event) => {
|
||||
void handleEvent(event).catch((err) => {
|
||||
runtime.error?.(`event handler failed: ${String(err)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
} catch (err) {
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (opts.abortSignal?.aborted && !daemonExitError) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
daemonLifecycle.dispose();
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
daemonLifecycle.stop();
|
||||
}
|
||||
}
|
||||
87
extensions/signal/src/monitor/access-policy.ts
Normal file
87
extensions/signal/src/monitor/access-policy.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
|
||||
import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "../../../../src/security/dm-policy-shared.js";
|
||||
import { isSignalSenderAllowed, type SignalSender } from "../identity.js";
|
||||
|
||||
type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type SignalGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
export async function resolveSignalAccessState(params: {
|
||||
accountId: string;
|
||||
dmPolicy: SignalDmPolicy;
|
||||
groupPolicy: SignalGroupPolicy;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
sender: SignalSender;
|
||||
}) {
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "signal",
|
||||
accountId: params.accountId,
|
||||
dmPolicy: params.dmPolicy,
|
||||
});
|
||||
const resolveAccessDecision = (isGroup: boolean) =>
|
||||
resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: params.groupPolicy,
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowEntries) => isSignalSenderAllowed(params.sender, allowEntries),
|
||||
});
|
||||
const dmAccess = resolveAccessDecision(false);
|
||||
return {
|
||||
resolveAccessDecision,
|
||||
dmAccess,
|
||||
effectiveDmAllow: dmAccess.effectiveAllowFrom,
|
||||
effectiveGroupAllow: dmAccess.effectiveGroupAllowFrom,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSignalDirectMessageAccess(params: {
|
||||
dmPolicy: SignalDmPolicy;
|
||||
dmAccessDecision: "allow" | "block" | "pairing";
|
||||
senderId: string;
|
||||
senderIdLine: string;
|
||||
senderDisplay: string;
|
||||
senderName?: string;
|
||||
accountId: string;
|
||||
sendPairingReply: (text: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (params.dmAccessDecision === "allow") {
|
||||
return true;
|
||||
}
|
||||
if (params.dmAccessDecision === "block") {
|
||||
if (params.dmPolicy !== "disabled") {
|
||||
params.log(`Blocked signal sender ${params.senderDisplay} (dmPolicy=${params.dmPolicy})`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (params.dmPolicy === "pairing") {
|
||||
await issuePairingChallenge({
|
||||
channel: "signal",
|
||||
senderId: params.senderId,
|
||||
senderIdLine: params.senderIdLine,
|
||||
meta: { name: params.senderName },
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "signal",
|
||||
id,
|
||||
accountId: params.accountId,
|
||||
meta,
|
||||
}),
|
||||
sendPairingReply: params.sendPairingReply,
|
||||
onCreated: () => {
|
||||
params.log(`signal pairing request sender=${params.senderId}`);
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
params.log(`signal pairing reply failed for ${params.senderId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
||||
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
||||
import { createSignalEventHandler } from "./event-handler.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
createSignalReceiveEvent,
|
||||
} from "./event-handler.test-harness.js";
|
||||
|
||||
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
|
||||
() => {
|
||||
const captureState: { ctx: MsgContext | undefined } = { ctx: undefined };
|
||||
return {
|
||||
sendTypingMock: vi.fn(),
|
||||
sendReadReceiptMock: vi.fn(),
|
||||
dispatchInboundMessageMock: vi.fn(
|
||||
async (params: {
|
||||
ctx: MsgContext;
|
||||
replyOptions?: { onReplyStart?: () => void | Promise<void> };
|
||||
}) => {
|
||||
captureState.ctx = params.ctx;
|
||||
await Promise.resolve(params.replyOptions?.onReplyStart?.());
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
||||
},
|
||||
),
|
||||
capture: captureState,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendTypingSignal: sendTypingMock,
|
||||
sendReadReceiptSignal: sendReadReceiptMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/auto-reply/dispatch.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/auto-reply/dispatch.js")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchInboundMessage: dispatchInboundMessageMock,
|
||||
dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock,
|
||||
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||
upsertChannelPairingRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("signal createSignalEventHandler inbound contract", () => {
|
||||
beforeEach(() => {
|
||||
capture.ctx = undefined;
|
||||
sendTypingMock.mockReset().mockResolvedValue(true);
|
||||
sendReadReceiptMock.mockReset().mockResolvedValue(true);
|
||||
dispatchInboundMessageMock.mockClear();
|
||||
});
|
||||
|
||||
it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "hi",
|
||||
attachments: [],
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expectInboundContextContract(capture.ctx!);
|
||||
const contextWithBody = capture.ctx!;
|
||||
// Sender should appear as prefix in group messages (no redundant [from:] suffix)
|
||||
expect(String(contextWithBody.Body ?? "")).toContain("Alice");
|
||||
expect(String(contextWithBody.Body ?? "")).toMatch(/Alice.*:/);
|
||||
expect(String(contextWithBody.Body ?? "")).not.toContain("[from:");
|
||||
});
|
||||
|
||||
it("normalizes direct chat To/OriginatingTo targets to canonical Signal ids", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
sourceNumber: "+15550002222",
|
||||
sourceName: "Bob",
|
||||
timestamp: 1700000000001,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
const context = capture.ctx!;
|
||||
expect(context.ChatType).toBe("direct");
|
||||
expect(context.To).toBe("+15550002222");
|
||||
expect(context.OriginatingTo).toBe("+15550002222");
|
||||
});
|
||||
|
||||
it("sends typing + read receipt for allowed DMs", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
},
|
||||
account: "+15550009999",
|
||||
blockStreaming: false,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
sendReadReceipts: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "hi",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendTypingMock).toHaveBeenCalledWith("+15550001111", expect.any(Object));
|
||||
expect(sendReadReceiptMock).toHaveBeenCalledWith(
|
||||
"signal:+15550001111",
|
||||
1700000000000,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: [] } },
|
||||
},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
account: "+15550009999",
|
||||
blockStreaming: false,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "/status",
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expect(capture.ctx?.CommandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards all fetched attachments via MediaPaths/MediaTypes", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
},
|
||||
ignoreAttachments: false,
|
||||
fetchAttachment: async ({ attachment }) => ({
|
||||
path: `/tmp/${String(attachment.id)}.dat`,
|
||||
contentType: attachment.id === "a1" ? "image/jpeg" : undefined,
|
||||
}),
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "",
|
||||
attachments: [{ id: "a1", contentType: "image/jpeg" }, { id: "a2" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expect(capture.ctx?.MediaPath).toBe("/tmp/a1.dat");
|
||||
expect(capture.ctx?.MediaType).toBe("image/jpeg");
|
||||
expect(capture.ctx?.MediaPaths).toEqual(["/tmp/a1.dat", "/tmp/a2.dat"]);
|
||||
expect(capture.ctx?.MediaUrls).toEqual(["/tmp/a1.dat", "/tmp/a2.dat"]);
|
||||
expect(capture.ctx?.MediaTypes).toEqual(["image/jpeg", "application/octet-stream"]);
|
||||
});
|
||||
|
||||
it("drops own UUID inbound messages when only accountUuid is configured", async () => {
|
||||
const ownUuid = "123e4567-e89b-12d3-a456-426614174000";
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"], accountUuid: ownUuid } },
|
||||
},
|
||||
account: undefined,
|
||||
accountUuid: ownUuid,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
sourceNumber: null,
|
||||
sourceUuid: ownUuid,
|
||||
dataMessage: {
|
||||
message: "self message",
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeUndefined();
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops sync envelopes when syncMessage is present but null", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
},
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
syncMessage: null,
|
||||
dataMessage: {
|
||||
message: "replayed sentTranscript envelope",
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeUndefined();
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/types.js";
|
||||
import { buildDispatchInboundCaptureMock } from "../../../../test/helpers/dispatch-inbound-capture.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
createSignalReceiveEvent,
|
||||
} from "./event-handler.test-harness.js";
|
||||
|
||||
type SignalMsgContext = Pick<MsgContext, "Body" | "WasMentioned"> & {
|
||||
Body?: string;
|
||||
WasMentioned?: boolean;
|
||||
};
|
||||
|
||||
let capturedCtx: SignalMsgContext | undefined;
|
||||
|
||||
function getCapturedCtx() {
|
||||
return capturedCtx as SignalMsgContext;
|
||||
}
|
||||
|
||||
vi.mock("../../../../src/auto-reply/dispatch.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/auto-reply/dispatch.js")>();
|
||||
return buildDispatchInboundCaptureMock(actual, (ctx) => {
|
||||
capturedCtx = ctx as SignalMsgContext;
|
||||
});
|
||||
});
|
||||
|
||||
import { createSignalEventHandler } from "./event-handler.js";
|
||||
import { renderSignalMentions } from "./mentions.js";
|
||||
|
||||
type GroupEventOpts = {
|
||||
message?: string;
|
||||
attachments?: unknown[];
|
||||
quoteText?: string;
|
||||
mentions?: Array<{
|
||||
uuid?: string;
|
||||
number?: string;
|
||||
start?: number;
|
||||
length?: number;
|
||||
}> | null;
|
||||
};
|
||||
|
||||
function makeGroupEvent(opts: GroupEventOpts) {
|
||||
return createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: opts.message ?? "",
|
||||
attachments: opts.attachments ?? [],
|
||||
quote: opts.quoteText ? { text: opts.quoteText } : undefined,
|
||||
mentions: opts.mentions ?? undefined,
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMentionHandler(params: {
|
||||
requireMention: boolean;
|
||||
mentionPattern?: string;
|
||||
historyLimit?: number;
|
||||
groupHistories?: ReturnType<typeof createBaseSignalEventHandlerDeps>["groupHistories"];
|
||||
}) {
|
||||
return createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: createSignalConfig({
|
||||
requireMention: params.requireMention,
|
||||
mentionPattern: params.mentionPattern,
|
||||
}),
|
||||
...(typeof params.historyLimit === "number" ? { historyLimit: params.historyLimit } : {}),
|
||||
...(params.groupHistories ? { groupHistories: params.groupHistories } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createMentionGatedHistoryHandler() {
|
||||
const groupHistories = new Map();
|
||||
const handler = createMentionHandler({ requireMention: true, historyLimit: 5, groupHistories });
|
||||
return { handler, groupHistories };
|
||||
}
|
||||
|
||||
function createSignalConfig(params: { requireMention: boolean; mentionPattern?: string }) {
|
||||
return {
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
groupChat: { mentionPatterns: [params.mentionPattern ?? "@bot"] },
|
||||
},
|
||||
channels: {
|
||||
signal: {
|
||||
groups: { "*": { requireMention: params.requireMention } },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function expectSkippedGroupHistory(opts: GroupEventOpts, expectedBody: string) {
|
||||
capturedCtx = undefined;
|
||||
const { handler, groupHistories } = createMentionGatedHistoryHandler();
|
||||
await handler(makeGroupEvent(opts));
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
const entries = groupHistories.get("g1");
|
||||
expect(entries).toBeTruthy();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].body).toBe(expectedBody);
|
||||
}
|
||||
|
||||
describe("signal mention gating", () => {
|
||||
it("drops group messages without mention when requireMention is configured", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hello everyone" }));
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows group messages with mention when requireMention is configured", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hey @bot what's up" }));
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
expect(getCapturedCtx()?.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("sets WasMentioned=false for group messages without mention when requireMention is off", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: false });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hello everyone" }));
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
expect(getCapturedCtx()?.WasMentioned).toBe(false);
|
||||
});
|
||||
|
||||
it("records pending history for skipped group messages", async () => {
|
||||
capturedCtx = undefined;
|
||||
const { handler, groupHistories } = createMentionGatedHistoryHandler();
|
||||
await handler(makeGroupEvent({ message: "hello from alice" }));
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
const entries = groupHistories.get("g1");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].sender).toBe("Alice");
|
||||
expect(entries[0].body).toBe("hello from alice");
|
||||
});
|
||||
|
||||
it("records attachment placeholder in pending history for skipped attachment-only group messages", async () => {
|
||||
await expectSkippedGroupHistory(
|
||||
{ message: "", attachments: [{ id: "a1" }] },
|
||||
"<media:attachment>",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes mixed-case parameterized attachment MIME in skipped pending history", async () => {
|
||||
capturedCtx = undefined;
|
||||
const groupHistories = new Map();
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: createSignalConfig({ requireMention: true }),
|
||||
historyLimit: 5,
|
||||
groupHistories,
|
||||
ignoreAttachments: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
makeGroupEvent({
|
||||
message: "",
|
||||
attachments: [{ contentType: " Audio/Ogg; codecs=opus " }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
const entries = groupHistories.get("g1");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].body).toBe("<media:audio>");
|
||||
});
|
||||
|
||||
it("summarizes multiple skipped attachments with stable file count wording", async () => {
|
||||
capturedCtx = undefined;
|
||||
const groupHistories = new Map();
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: createSignalConfig({ requireMention: true }),
|
||||
historyLimit: 5,
|
||||
groupHistories,
|
||||
ignoreAttachments: false,
|
||||
fetchAttachment: async ({ attachment }) => ({
|
||||
path: `/tmp/${String(attachment.id)}.bin`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
makeGroupEvent({
|
||||
message: "",
|
||||
attachments: [{ id: "a1" }, { id: "a2" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
const entries = groupHistories.get("g1");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].body).toBe("[2 files attached]");
|
||||
});
|
||||
|
||||
it("records quote text in pending history for skipped quote-only group messages", async () => {
|
||||
await expectSkippedGroupHistory({ message: "", quoteText: "quoted context" }, "quoted context");
|
||||
});
|
||||
|
||||
it("bypasses mention gating for authorized control commands", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "/help" }));
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hydrates mention placeholders before trimming so offsets stay aligned", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: false });
|
||||
|
||||
const placeholder = "\uFFFC";
|
||||
const message = `\n${placeholder} hi ${placeholder}`;
|
||||
const firstStart = message.indexOf(placeholder);
|
||||
const secondStart = message.indexOf(placeholder, firstStart + 1);
|
||||
|
||||
await handler(
|
||||
makeGroupEvent({
|
||||
message,
|
||||
mentions: [
|
||||
{ uuid: "123e4567", start: firstStart, length: placeholder.length },
|
||||
{ number: "+15550002222", start: secondStart, length: placeholder.length },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
const body = String(getCapturedCtx()?.Body ?? "");
|
||||
expect(body).toContain("@123e4567 hi @+15550002222");
|
||||
expect(body).not.toContain(placeholder);
|
||||
});
|
||||
|
||||
it("counts mention metadata replacements toward requireMention gating", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({
|
||||
requireMention: true,
|
||||
mentionPattern: "@123e4567",
|
||||
});
|
||||
|
||||
const placeholder = "\uFFFC";
|
||||
const message = ` ${placeholder} ping`;
|
||||
const start = message.indexOf(placeholder);
|
||||
|
||||
await handler(
|
||||
makeGroupEvent({
|
||||
message,
|
||||
mentions: [{ uuid: "123e4567", start, length: placeholder.length }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
expect(String(getCapturedCtx()?.Body ?? "")).toContain("@123e4567");
|
||||
expect(getCapturedCtx()?.WasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSignalMentions", () => {
|
||||
const PLACEHOLDER = "\uFFFC";
|
||||
|
||||
it("returns the original message when no mentions are provided", () => {
|
||||
const message = `${PLACEHOLDER} ping`;
|
||||
expect(renderSignalMentions(message, null)).toBe(message);
|
||||
expect(renderSignalMentions(message, [])).toBe(message);
|
||||
});
|
||||
|
||||
it("replaces placeholder code points using mention metadata", () => {
|
||||
const message = `${PLACEHOLDER} hi ${PLACEHOLDER}!`;
|
||||
const normalized = renderSignalMentions(message, [
|
||||
{ uuid: "abc-123", start: 0, length: 1 },
|
||||
{ number: "+15550005555", start: message.lastIndexOf(PLACEHOLDER), length: 1 },
|
||||
]);
|
||||
|
||||
expect(normalized).toBe("@abc-123 hi @+15550005555!");
|
||||
});
|
||||
|
||||
it("skips mentions that lack identifiers or out-of-bounds spans", () => {
|
||||
const message = `${PLACEHOLDER} hi`;
|
||||
const normalized = renderSignalMentions(message, [
|
||||
{ name: "ignored" },
|
||||
{ uuid: "valid", start: 0, length: 1 },
|
||||
{ number: "+1555", start: 999, length: 1 },
|
||||
]);
|
||||
|
||||
expect(normalized).toBe("@valid hi");
|
||||
});
|
||||
|
||||
it("clamps and truncates fractional mention offsets", () => {
|
||||
const message = `${PLACEHOLDER} ping`;
|
||||
const normalized = renderSignalMentions(message, [{ uuid: "valid", start: -0.7, length: 1.9 }]);
|
||||
|
||||
expect(normalized).toBe("@valid ping");
|
||||
});
|
||||
});
|
||||
49
extensions/signal/src/monitor/event-handler.test-harness.ts
Normal file
49
extensions/signal/src/monitor/event-handler.test-harness.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SignalEventHandlerDeps, SignalReactionMessage } from "./event-handler.types.js";
|
||||
|
||||
export function createBaseSignalEventHandlerDeps(
|
||||
overrides: Partial<SignalEventHandlerDeps> = {},
|
||||
): SignalEventHandlerDeps {
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
runtime: { log: () => {}, error: () => {} } as any,
|
||||
cfg: {},
|
||||
baseUrl: "http://localhost",
|
||||
accountId: "default",
|
||||
historyLimit: 5,
|
||||
groupHistories: new Map(),
|
||||
textLimit: 4000,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["*"],
|
||||
groupPolicy: "open",
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
mediaMaxBytes: 1024,
|
||||
ignoreAttachments: true,
|
||||
sendReadReceipts: false,
|
||||
readReceiptsViaDaemon: false,
|
||||
fetchAttachment: async () => null,
|
||||
deliverReplies: async () => {},
|
||||
resolveSignalReactionTargets: () => [],
|
||||
isSignalReactionMessage: (
|
||||
_reaction: SignalReactionMessage | null | undefined,
|
||||
): _reaction is SignalReactionMessage => false,
|
||||
shouldEmitSignalReactionNotification: () => false,
|
||||
buildSignalReactionSystemEventText: () => "reaction",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSignalReceiveEvent(envelopeOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
event: "receive",
|
||||
data: JSON.stringify({
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Alice",
|
||||
timestamp: 1700000000000,
|
||||
...envelopeOverrides,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
804
extensions/signal/src/monitor/event-handler.ts
Normal file
804
extensions/signal/src/monitor/event-handler.ts
Normal file
@@ -0,0 +1,804 @@
|
||||
import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js";
|
||||
import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js";
|
||||
import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js";
|
||||
import {
|
||||
formatInboundEnvelope,
|
||||
formatInboundFromLabel,
|
||||
resolveEnvelopeFormatOptions,
|
||||
} from "../../../../src/auto-reply/envelope.js";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
} from "../../../../src/auto-reply/reply/history.js";
|
||||
import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
} from "../../../../src/auto-reply/reply/mentions.js";
|
||||
import { createReplyDispatcherWithTyping } from "../../../../src/auto-reply/reply/reply-dispatcher.js";
|
||||
import { resolveControlCommandGate } from "../../../../src/channels/command-gating.js";
|
||||
import {
|
||||
createChannelInboundDebouncer,
|
||||
shouldDebounceTextInbound,
|
||||
} from "../../../../src/channels/inbound-debounce-policy.js";
|
||||
import { logInboundDrop, logTypingFailure } from "../../../../src/channels/logging.js";
|
||||
import { resolveMentionGatingWithBypass } from "../../../../src/channels/mention-gating.js";
|
||||
import { normalizeSignalMessagingTarget } from "../../../../src/channels/plugins/normalize/signal.js";
|
||||
import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js";
|
||||
import { recordInboundSession } from "../../../../src/channels/session.js";
|
||||
import { createTypingCallbacks } from "../../../../src/channels/typing.js";
|
||||
import { resolveChannelGroupRequireMention } from "../../../../src/config/group-policy.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
|
||||
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
|
||||
import { kindFromMime } from "../../../../src/media/mime.js";
|
||||
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
} from "../../../../src/security/dm-policy-shared.js";
|
||||
import { normalizeE164 } from "../../../../src/utils.js";
|
||||
import {
|
||||
formatSignalPairingIdLine,
|
||||
formatSignalSenderDisplay,
|
||||
formatSignalSenderId,
|
||||
isSignalSenderAllowed,
|
||||
normalizeSignalAllowRecipient,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
type SignalSender,
|
||||
} from "../identity.js";
|
||||
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
|
||||
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
|
||||
import type {
|
||||
SignalEnvelope,
|
||||
SignalEventHandlerDeps,
|
||||
SignalReactionMessage,
|
||||
SignalReceivePayload,
|
||||
} from "./event-handler.types.js";
|
||||
import { renderSignalMentions } from "./mentions.js";
|
||||
|
||||
function formatAttachmentKindCount(kind: string, count: number): string {
|
||||
if (kind === "attachment") {
|
||||
return `${count} file${count > 1 ? "s" : ""}`;
|
||||
}
|
||||
return `${count} ${kind}${count > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
function formatAttachmentSummaryPlaceholder(contentTypes: Array<string | undefined>): string {
|
||||
const kindCounts = new Map<string, number>();
|
||||
for (const contentType of contentTypes) {
|
||||
const kind = kindFromMime(contentType) ?? "attachment";
|
||||
kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
const parts = [...kindCounts.entries()].map(([kind, count]) =>
|
||||
formatAttachmentKindCount(kind, count),
|
||||
);
|
||||
return `[${parts.join(" + ")} attached]`;
|
||||
}
|
||||
|
||||
function resolveSignalInboundRoute(params: {
|
||||
cfg: SignalEventHandlerDeps["cfg"];
|
||||
accountId: SignalEventHandlerDeps["accountId"];
|
||||
isGroup: boolean;
|
||||
groupId?: string;
|
||||
senderPeerId: string;
|
||||
}) {
|
||||
return resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "signal",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: params.isGroup ? "group" : "direct",
|
||||
id: params.isGroup ? (params.groupId ?? "unknown") : params.senderPeerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
type SignalInboundEntry = {
|
||||
senderName: string;
|
||||
senderDisplay: string;
|
||||
senderRecipient: string;
|
||||
senderPeerId: string;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
isGroup: boolean;
|
||||
bodyText: string;
|
||||
commandBody: string;
|
||||
timestamp?: number;
|
||||
messageId?: string;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
mediaPaths?: string[];
|
||||
mediaTypes?: string[];
|
||||
commandAuthorized: boolean;
|
||||
wasMentioned?: boolean;
|
||||
};
|
||||
|
||||
async function handleSignalInboundMessage(entry: SignalInboundEntry) {
|
||||
const fromLabel = formatInboundFromLabel({
|
||||
isGroup: entry.isGroup,
|
||||
groupLabel: entry.groupName ?? undefined,
|
||||
groupId: entry.groupId ?? "unknown",
|
||||
groupFallback: "Group",
|
||||
directLabel: entry.senderName,
|
||||
directId: entry.senderDisplay,
|
||||
});
|
||||
const route = resolveSignalInboundRoute({
|
||||
cfg: deps.cfg,
|
||||
accountId: deps.accountId,
|
||||
isGroup: entry.isGroup,
|
||||
groupId: entry.groupId,
|
||||
senderPeerId: entry.senderPeerId,
|
||||
});
|
||||
const storePath = resolveStorePath(deps.cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg);
|
||||
const previousTimestamp = readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Signal",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp ?? undefined,
|
||||
body: entry.bodyText,
|
||||
chatType: entry.isGroup ? "group" : "direct",
|
||||
sender: { name: entry.senderName, id: entry.senderDisplay },
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
let combinedBody = body;
|
||||
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
|
||||
if (entry.isGroup && historyKey) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey,
|
||||
limit: deps.historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (historyEntry) =>
|
||||
formatInboundEnvelope({
|
||||
channel: "Signal",
|
||||
from: fromLabel,
|
||||
timestamp: historyEntry.timestamp,
|
||||
body: `${historyEntry.body}${
|
||||
historyEntry.messageId ? ` [id:${historyEntry.messageId}]` : ""
|
||||
}`,
|
||||
chatType: "group",
|
||||
senderLabel: historyEntry.sender,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const signalToRaw = entry.isGroup
|
||||
? `group:${entry.groupId}`
|
||||
: `signal:${entry.senderRecipient}`;
|
||||
const signalTo = normalizeSignalMessagingTarget(signalToRaw) ?? signalToRaw;
|
||||
const inboundHistory =
|
||||
entry.isGroup && historyKey && deps.historyLimit > 0
|
||||
? (deps.groupHistories.get(historyKey) ?? []).map((historyEntry) => ({
|
||||
sender: historyEntry.sender,
|
||||
body: historyEntry.body,
|
||||
timestamp: historyEntry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: entry.bodyText,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: entry.bodyText,
|
||||
CommandBody: entry.commandBody,
|
||||
BodyForCommands: entry.commandBody,
|
||||
From: entry.isGroup
|
||||
? `group:${entry.groupId ?? "unknown"}`
|
||||
: `signal:${entry.senderRecipient}`,
|
||||
To: signalTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: entry.isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: entry.isGroup ? (entry.groupName ?? undefined) : undefined,
|
||||
SenderName: entry.senderName,
|
||||
SenderId: entry.senderDisplay,
|
||||
Provider: "signal" as const,
|
||||
Surface: "signal" as const,
|
||||
MessageSid: entry.messageId,
|
||||
Timestamp: entry.timestamp ?? undefined,
|
||||
MediaPath: entry.mediaPath,
|
||||
MediaType: entry.mediaType,
|
||||
MediaUrl: entry.mediaPath,
|
||||
MediaPaths: entry.mediaPaths,
|
||||
MediaUrls: entry.mediaPaths,
|
||||
MediaTypes: entry.mediaTypes,
|
||||
WasMentioned: entry.isGroup ? entry.wasMentioned === true : undefined,
|
||||
CommandAuthorized: entry.commandAuthorized,
|
||||
OriginatingChannel: "signal" as const,
|
||||
OriginatingTo: signalTo,
|
||||
});
|
||||
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: !entry.isGroup
|
||||
? {
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "signal",
|
||||
to: entry.senderRecipient,
|
||||
accountId: route.accountId,
|
||||
mainDmOwnerPin: (() => {
|
||||
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: deps.cfg.session?.dmScope,
|
||||
allowFrom: deps.allowFrom,
|
||||
normalizeEntry: normalizeSignalAllowRecipient,
|
||||
});
|
||||
if (!pinnedOwner) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ownerRecipient: pinnedOwner,
|
||||
senderRecipient: entry.senderRecipient,
|
||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||
logVerbose(
|
||||
`signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logVerbose(`signal: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = body.slice(0, 200).replace(/\\n/g, "\\\\n");
|
||||
logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
|
||||
}
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: deps.cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "signal",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: async () => {
|
||||
if (!ctxPayload.To) {
|
||||
return;
|
||||
}
|
||||
await sendTypingSignal(ctxPayload.To, {
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
accountId: deps.accountId,
|
||||
});
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerbose,
|
||||
channel: "signal",
|
||||
target: ctxPayload.To ?? undefined,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
await deps.deliverReplies({
|
||||
replies: [payload],
|
||||
target: ctxPayload.To,
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
accountId: deps.accountId,
|
||||
runtime: deps.runtime,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
textLimit: deps.textLimit,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
});
|
||||
|
||||
const { queuedFinal } = await dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg: deps.cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
if (!queuedFinal) {
|
||||
if (entry.isGroup && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey,
|
||||
limit: deps.historyLimit,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entry.isGroup && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey,
|
||||
limit: deps.historyLimit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<SignalInboundEntry>({
|
||||
cfg: deps.cfg,
|
||||
channel: "signal",
|
||||
buildKey: (entry) => {
|
||||
const conversationId = entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId;
|
||||
if (!conversationId || !entry.senderPeerId) {
|
||||
return null;
|
||||
}
|
||||
return `signal:${deps.accountId}:${conversationId}:${entry.senderPeerId}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
return shouldDebounceTextInbound({
|
||||
text: entry.bodyText,
|
||||
cfg: deps.cfg,
|
||||
hasMedia: Boolean(entry.mediaPath || entry.mediaType || entry.mediaPaths?.length),
|
||||
});
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await handleSignalInboundMessage(last);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.bodyText)
|
||||
.filter(Boolean)
|
||||
.join("\\n");
|
||||
if (!combinedText.trim()) {
|
||||
return;
|
||||
}
|
||||
await handleSignalInboundMessage({
|
||||
...last,
|
||||
bodyText: combinedText,
|
||||
mediaPath: undefined,
|
||||
mediaType: undefined,
|
||||
mediaPaths: undefined,
|
||||
mediaTypes: undefined,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
deps.runtime.error?.(`signal debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
function handleReactionOnlyInbound(params: {
|
||||
envelope: SignalEnvelope;
|
||||
sender: SignalSender;
|
||||
senderDisplay: string;
|
||||
reaction: SignalReactionMessage;
|
||||
hasBodyContent: boolean;
|
||||
resolveAccessDecision: (isGroup: boolean) => {
|
||||
decision: "allow" | "block" | "pairing";
|
||||
reason: string;
|
||||
};
|
||||
}): boolean {
|
||||
if (params.hasBodyContent) {
|
||||
return false;
|
||||
}
|
||||
if (params.reaction.isRemove) {
|
||||
return true; // Ignore reaction removals
|
||||
}
|
||||
const emojiLabel = params.reaction.emoji?.trim() || "emoji";
|
||||
const senderName = params.envelope.sourceName ?? params.senderDisplay;
|
||||
logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`);
|
||||
const groupId = params.reaction.groupInfo?.groupId ?? undefined;
|
||||
const groupName = params.reaction.groupInfo?.groupName ?? undefined;
|
||||
const isGroup = Boolean(groupId);
|
||||
const reactionAccess = params.resolveAccessDecision(isGroup);
|
||||
if (reactionAccess.decision !== "allow") {
|
||||
logVerbose(
|
||||
`Blocked signal reaction sender ${params.senderDisplay} (${reactionAccess.reason})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const targets = deps.resolveSignalReactionTargets(params.reaction);
|
||||
const shouldNotify = deps.shouldEmitSignalReactionNotification({
|
||||
mode: deps.reactionMode,
|
||||
account: deps.account,
|
||||
targets,
|
||||
sender: params.sender,
|
||||
allowlist: deps.reactionAllowlist,
|
||||
});
|
||||
if (!shouldNotify) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const senderPeerId = resolveSignalPeerId(params.sender);
|
||||
const route = resolveSignalInboundRoute({
|
||||
cfg: deps.cfg,
|
||||
accountId: deps.accountId,
|
||||
isGroup,
|
||||
groupId,
|
||||
senderPeerId,
|
||||
});
|
||||
const groupLabel = isGroup ? `${groupName ?? "Signal Group"} id:${groupId}` : undefined;
|
||||
const messageId = params.reaction.targetSentTimestamp
|
||||
? String(params.reaction.targetSentTimestamp)
|
||||
: "unknown";
|
||||
const text = deps.buildSignalReactionSystemEventText({
|
||||
emojiLabel,
|
||||
actorLabel: senderName,
|
||||
messageId,
|
||||
targetLabel: targets[0]?.display,
|
||||
groupLabel,
|
||||
});
|
||||
const senderId = formatSignalSenderId(params.sender);
|
||||
const contextKey = [
|
||||
"signal",
|
||||
"reaction",
|
||||
"added",
|
||||
messageId,
|
||||
senderId,
|
||||
emojiLabel,
|
||||
groupId ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":");
|
||||
enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey });
|
||||
return true;
|
||||
}
|
||||
|
||||
return async (event: { event?: string; data?: string }) => {
|
||||
if (event.event !== "receive" || !event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: SignalReceivePayload | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data) as SignalReceivePayload;
|
||||
} catch (err) {
|
||||
deps.runtime.error?.(`failed to parse event: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (payload?.exception?.message) {
|
||||
deps.runtime.error?.(`receive exception: ${payload.exception.message}`);
|
||||
}
|
||||
const envelope = payload?.envelope;
|
||||
if (!envelope) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for syncMessage (e.g., sentTranscript from other devices)
|
||||
// We need to check if it's from our own account to prevent self-reply loops
|
||||
const sender = resolveSignalSender(envelope);
|
||||
if (!sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the message is from our own account to prevent loop/self-reply
|
||||
// This handles both phone number and UUID based identification
|
||||
const normalizedAccount = deps.account ? normalizeE164(deps.account) : undefined;
|
||||
const isOwnMessage =
|
||||
(sender.kind === "phone" && normalizedAccount != null && sender.e164 === normalizedAccount) ||
|
||||
(sender.kind === "uuid" && deps.accountUuid != null && sender.raw === deps.accountUuid);
|
||||
if (isOwnMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter all sync messages (sentTranscript, readReceipts, etc.).
|
||||
// signal-cli may set syncMessage to null instead of omitting it, so
|
||||
// check property existence rather than truthiness to avoid replaying
|
||||
// the bot's own sent messages on daemon restart.
|
||||
if ("syncMessage" in envelope) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage;
|
||||
const reaction = deps.isSignalReactionMessage(envelope.reactionMessage)
|
||||
? envelope.reactionMessage
|
||||
: deps.isSignalReactionMessage(dataMessage?.reaction)
|
||||
? dataMessage?.reaction
|
||||
: null;
|
||||
|
||||
// Replace  (object replacement character) with @uuid or @phone from mentions
|
||||
// Signal encodes mentions as the object replacement character; hydrate them from metadata first.
|
||||
const rawMessage = dataMessage?.message ?? "";
|
||||
const normalizedMessage = renderSignalMentions(rawMessage, dataMessage?.mentions);
|
||||
const messageText = normalizedMessage.trim();
|
||||
|
||||
const quoteText = dataMessage?.quote?.text?.trim() ?? "";
|
||||
const hasBodyContent =
|
||||
Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length);
|
||||
const senderDisplay = formatSignalSenderDisplay(sender);
|
||||
const { resolveAccessDecision, dmAccess, effectiveDmAllow, effectiveGroupAllow } =
|
||||
await resolveSignalAccessState({
|
||||
accountId: deps.accountId,
|
||||
dmPolicy: deps.dmPolicy,
|
||||
groupPolicy: deps.groupPolicy,
|
||||
allowFrom: deps.allowFrom,
|
||||
groupAllowFrom: deps.groupAllowFrom,
|
||||
sender,
|
||||
});
|
||||
|
||||
if (
|
||||
reaction &&
|
||||
handleReactionOnlyInbound({
|
||||
envelope,
|
||||
sender,
|
||||
senderDisplay,
|
||||
reaction,
|
||||
hasBodyContent,
|
||||
resolveAccessDecision,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!dataMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const senderRecipient = resolveSignalRecipient(sender);
|
||||
const senderPeerId = resolveSignalPeerId(sender);
|
||||
const senderAllowId = formatSignalSenderId(sender);
|
||||
if (!senderRecipient) {
|
||||
return;
|
||||
}
|
||||
const senderIdLine = formatSignalPairingIdLine(sender);
|
||||
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
|
||||
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
|
||||
const isGroup = Boolean(groupId);
|
||||
|
||||
if (!isGroup) {
|
||||
const allowedDirectMessage = await handleSignalDirectMessageAccess({
|
||||
dmPolicy: deps.dmPolicy,
|
||||
dmAccessDecision: dmAccess.decision,
|
||||
senderId: senderAllowId,
|
||||
senderIdLine,
|
||||
senderDisplay,
|
||||
senderName: envelope.sourceName ?? undefined,
|
||||
accountId: deps.accountId,
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageSignal(`signal:${senderRecipient}`, text, {
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
accountId: deps.accountId,
|
||||
});
|
||||
},
|
||||
log: logVerbose,
|
||||
});
|
||||
if (!allowedDirectMessage) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isGroup) {
|
||||
const groupAccess = resolveAccessDecision(true);
|
||||
if (groupAccess.decision !== "allow") {
|
||||
if (groupAccess.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
|
||||
logVerbose("Blocked signal group message (groupPolicy: disabled)");
|
||||
} else if (groupAccess.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
|
||||
logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)");
|
||||
} else {
|
||||
logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllow = isGroup ? deps.allowFrom : effectiveDmAllow;
|
||||
const ownerAllowedForCommands = isSignalSenderAllowed(sender, commandDmAllow);
|
||||
const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow);
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandDmAllow.length > 0, allowed: ownerAllowedForCommands },
|
||||
{ configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands },
|
||||
],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
if (isGroup && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "signal",
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderDisplay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const route = resolveSignalInboundRoute({
|
||||
cfg: deps.cfg,
|
||||
accountId: deps.accountId,
|
||||
isGroup,
|
||||
groupId,
|
||||
senderPeerId,
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(deps.cfg, route.agentId);
|
||||
const wasMentioned = isGroup && matchesMentionPatterns(messageText, mentionRegexes);
|
||||
const requireMention =
|
||||
isGroup &&
|
||||
resolveChannelGroupRequireMention({
|
||||
cfg: deps.cfg,
|
||||
channel: "signal",
|
||||
groupId,
|
||||
accountId: deps.accountId,
|
||||
});
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention,
|
||||
wasMentioned,
|
||||
implicitMention: false,
|
||||
hasAnyMention: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
commandAuthorized,
|
||||
});
|
||||
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||
if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "signal",
|
||||
reason: "no mention",
|
||||
target: senderDisplay,
|
||||
});
|
||||
const quoteText = dataMessage.quote?.text?.trim() || "";
|
||||
const pendingPlaceholder = (() => {
|
||||
if (!dataMessage.attachments?.length) {
|
||||
return "";
|
||||
}
|
||||
// When we're skipping a message we intentionally avoid downloading attachments.
|
||||
// Still record a useful placeholder for pending-history context.
|
||||
if (deps.ignoreAttachments) {
|
||||
return "<media:attachment>";
|
||||
}
|
||||
const attachmentTypes = (dataMessage.attachments ?? []).map((attachment) =>
|
||||
typeof attachment?.contentType === "string" ? attachment.contentType : undefined,
|
||||
);
|
||||
if (attachmentTypes.length > 1) {
|
||||
return formatAttachmentSummaryPlaceholder(attachmentTypes);
|
||||
}
|
||||
const firstContentType = dataMessage.attachments?.[0]?.contentType;
|
||||
const pendingKind = kindFromMime(firstContentType ?? undefined);
|
||||
return pendingKind ? `<media:${pendingKind}>` : "<media:attachment>";
|
||||
})();
|
||||
const pendingBodyText = messageText || pendingPlaceholder || quoteText;
|
||||
const historyKey = groupId ?? "unknown";
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey,
|
||||
limit: deps.historyLimit,
|
||||
entry: {
|
||||
sender: envelope.sourceName ?? senderDisplay,
|
||||
body: pendingBodyText,
|
||||
timestamp: envelope.timestamp ?? undefined,
|
||||
messageId:
|
||||
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
const mediaPaths: string[] = [];
|
||||
const mediaTypes: string[] = [];
|
||||
let placeholder = "";
|
||||
const attachments = dataMessage.attachments ?? [];
|
||||
if (!deps.ignoreAttachments) {
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment?.id) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const fetched = await deps.fetchAttachment({
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
attachment,
|
||||
sender: senderRecipient,
|
||||
groupId,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
});
|
||||
if (fetched) {
|
||||
mediaPaths.push(fetched.path);
|
||||
mediaTypes.push(
|
||||
fetched.contentType ?? attachment.contentType ?? "application/octet-stream",
|
||||
);
|
||||
if (!mediaPath) {
|
||||
mediaPath = fetched.path;
|
||||
mediaType = fetched.contentType ?? attachment.contentType ?? undefined;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
deps.runtime.error?.(danger(`attachment fetch failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaPaths.length > 1) {
|
||||
placeholder = formatAttachmentSummaryPlaceholder(mediaTypes);
|
||||
} else {
|
||||
const kind = kindFromMime(mediaType ?? undefined);
|
||||
if (kind) {
|
||||
placeholder = `<media:${kind}>`;
|
||||
} else if (attachments.length) {
|
||||
placeholder = "<media:attachment>";
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
|
||||
if (!bodyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const receiptTimestamp =
|
||||
typeof envelope.timestamp === "number"
|
||||
? envelope.timestamp
|
||||
: typeof dataMessage.timestamp === "number"
|
||||
? dataMessage.timestamp
|
||||
: undefined;
|
||||
if (deps.sendReadReceipts && !deps.readReceiptsViaDaemon && !isGroup && receiptTimestamp) {
|
||||
try {
|
||||
await sendReadReceiptSignal(`signal:${senderRecipient}`, receiptTimestamp, {
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
accountId: deps.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`signal read receipt failed for ${senderDisplay}: ${String(err)}`);
|
||||
}
|
||||
} else if (
|
||||
deps.sendReadReceipts &&
|
||||
!deps.readReceiptsViaDaemon &&
|
||||
!isGroup &&
|
||||
!receiptTimestamp
|
||||
) {
|
||||
logVerbose(`signal read receipt skipped (missing timestamp) for ${senderDisplay}`);
|
||||
}
|
||||
|
||||
const senderName = envelope.sourceName ?? senderDisplay;
|
||||
const messageId =
|
||||
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined;
|
||||
await inboundDebouncer.enqueue({
|
||||
senderName,
|
||||
senderDisplay,
|
||||
senderRecipient,
|
||||
senderPeerId,
|
||||
groupId,
|
||||
groupName,
|
||||
isGroup,
|
||||
bodyText,
|
||||
commandBody: messageText,
|
||||
timestamp: envelope.timestamp ?? undefined,
|
||||
messageId,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
mediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
mediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
commandAuthorized,
|
||||
wasMentioned: effectiveWasMentioned,
|
||||
});
|
||||
};
|
||||
}
|
||||
131
extensions/signal/src/monitor/event-handler.types.ts
Normal file
131
extensions/signal/src/monitor/event-handler.types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js";
|
||||
import type { ReplyPayload } from "../../../../src/auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import type {
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
SignalReactionNotificationMode,
|
||||
} from "../../../../src/config/types.js";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import type { SignalSender } from "../identity.js";
|
||||
|
||||
export type SignalEnvelope = {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
sourceName?: string | null;
|
||||
timestamp?: number | null;
|
||||
dataMessage?: SignalDataMessage | null;
|
||||
editMessage?: { dataMessage?: SignalDataMessage | null } | null;
|
||||
syncMessage?: unknown;
|
||||
reactionMessage?: SignalReactionMessage | null;
|
||||
};
|
||||
|
||||
export type SignalMention = {
|
||||
name?: string | null;
|
||||
number?: string | null;
|
||||
uuid?: string | null;
|
||||
start?: number | null;
|
||||
length?: number | null;
|
||||
};
|
||||
|
||||
export type SignalDataMessage = {
|
||||
timestamp?: number;
|
||||
message?: string | null;
|
||||
attachments?: Array<SignalAttachment>;
|
||||
mentions?: Array<SignalMention> | null;
|
||||
groupInfo?: {
|
||||
groupId?: string | null;
|
||||
groupName?: string | null;
|
||||
} | null;
|
||||
quote?: { text?: string | null } | null;
|
||||
reaction?: SignalReactionMessage | null;
|
||||
};
|
||||
|
||||
export type SignalReactionMessage = {
|
||||
emoji?: string | null;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
targetSentTimestamp?: number | null;
|
||||
isRemove?: boolean | null;
|
||||
groupInfo?: {
|
||||
groupId?: string | null;
|
||||
groupName?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SignalAttachment = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
filename?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export type SignalReactionTarget = {
|
||||
kind: "phone" | "uuid";
|
||||
id: string;
|
||||
display: string;
|
||||
};
|
||||
|
||||
export type SignalReceivePayload = {
|
||||
envelope?: SignalEnvelope | null;
|
||||
exception?: { message?: string } | null;
|
||||
};
|
||||
|
||||
export type SignalEventHandlerDeps = {
|
||||
runtime: RuntimeEnv;
|
||||
cfg: OpenClawConfig;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountUuid?: string;
|
||||
accountId: string;
|
||||
blockStreaming?: boolean;
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
textLimit: number;
|
||||
dmPolicy: DmPolicy;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
groupPolicy: GroupPolicy;
|
||||
reactionMode: SignalReactionNotificationMode;
|
||||
reactionAllowlist: string[];
|
||||
mediaMaxBytes: number;
|
||||
ignoreAttachments: boolean;
|
||||
sendReadReceipts: boolean;
|
||||
readReceiptsViaDaemon: boolean;
|
||||
fetchAttachment: (params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
attachment: SignalAttachment;
|
||||
sender?: string;
|
||||
groupId?: string;
|
||||
maxBytes: number;
|
||||
}) => Promise<{ path: string; contentType?: string } | null>;
|
||||
deliverReplies: (params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
}) => Promise<void>;
|
||||
resolveSignalReactionTargets: (reaction: SignalReactionMessage) => SignalReactionTarget[];
|
||||
isSignalReactionMessage: (
|
||||
reaction: SignalReactionMessage | null | undefined,
|
||||
) => reaction is SignalReactionMessage;
|
||||
shouldEmitSignalReactionNotification: (params: {
|
||||
mode?: SignalReactionNotificationMode;
|
||||
account?: string | null;
|
||||
targets?: SignalReactionTarget[];
|
||||
sender?: SignalSender | null;
|
||||
allowlist?: string[];
|
||||
}) => boolean;
|
||||
buildSignalReactionSystemEventText: (params: {
|
||||
emojiLabel: string;
|
||||
actorLabel: string;
|
||||
messageId: string;
|
||||
targetLabel?: string;
|
||||
groupLabel?: string;
|
||||
}) => string;
|
||||
};
|
||||
56
extensions/signal/src/monitor/mentions.ts
Normal file
56
extensions/signal/src/monitor/mentions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { SignalMention } from "./event-handler.types.js";
|
||||
|
||||
const OBJECT_REPLACEMENT = "\uFFFC";
|
||||
|
||||
function isValidMention(mention: SignalMention | null | undefined): mention is SignalMention {
|
||||
if (!mention) {
|
||||
return false;
|
||||
}
|
||||
if (!(mention.uuid || mention.number)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof mention.start !== "number" || Number.isNaN(mention.start)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof mention.length !== "number" || Number.isNaN(mention.length)) {
|
||||
return false;
|
||||
}
|
||||
return mention.length > 0;
|
||||
}
|
||||
|
||||
function clampBounds(start: number, length: number, textLength: number) {
|
||||
const safeStart = Math.max(0, Math.trunc(start));
|
||||
const safeLength = Math.max(0, Math.trunc(length));
|
||||
const safeEnd = Math.min(textLength, safeStart + safeLength);
|
||||
return { start: safeStart, end: safeEnd };
|
||||
}
|
||||
|
||||
export function renderSignalMentions(message: string, mentions?: SignalMention[] | null) {
|
||||
if (!message || !mentions?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
let normalized = message;
|
||||
const candidates = mentions.filter(isValidMention).toSorted((a, b) => b.start! - a.start!);
|
||||
|
||||
for (const mention of candidates) {
|
||||
const identifier = mention.uuid ?? mention.number;
|
||||
if (!identifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { start, end } = clampBounds(mention.start!, mention.length!, normalized.length);
|
||||
if (start >= end) {
|
||||
continue;
|
||||
}
|
||||
const slice = normalized.slice(start, end);
|
||||
|
||||
if (!slice.includes(OBJECT_REPLACEMENT)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized = normalized.slice(0, start) + `@${identifier}` + normalized.slice(end);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
69
extensions/signal/src/probe.test.ts
Normal file
69
extensions/signal/src/probe.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { classifySignalCliLogLine } from "./daemon.js";
|
||||
import { probeSignal } from "./probe.js";
|
||||
|
||||
const signalCheckMock = vi.fn();
|
||||
const signalRpcRequestMock = vi.fn();
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
signalCheck: (...args: unknown[]) => signalCheckMock(...args),
|
||||
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
|
||||
}));
|
||||
|
||||
describe("probeSignal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("extracts version from {version} result", async () => {
|
||||
signalCheckMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
error: null,
|
||||
});
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ version: "0.13.22" });
|
||||
|
||||
const res = await probeSignal("http://127.0.0.1:8080", 1000);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.version).toBe("0.13.22");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns ok=false when /check fails", async () => {
|
||||
signalCheckMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
error: "HTTP 503",
|
||||
});
|
||||
|
||||
const res = await probeSignal("http://127.0.0.1:8080", 1000);
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.version).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifySignalCliLogLine", () => {
|
||||
it("treats INFO/DEBUG as log (even if emitted on stderr)", () => {
|
||||
expect(classifySignalCliLogLine("INFO DaemonCommand - Started")).toBe("log");
|
||||
expect(classifySignalCliLogLine("DEBUG Something")).toBe("log");
|
||||
});
|
||||
|
||||
it("treats WARN/ERROR as error", () => {
|
||||
expect(classifySignalCliLogLine("WARN Something")).toBe("error");
|
||||
expect(classifySignalCliLogLine("WARNING Something")).toBe("error");
|
||||
expect(classifySignalCliLogLine("ERROR Something")).toBe("error");
|
||||
});
|
||||
|
||||
it("treats failures without explicit severity as error", () => {
|
||||
expect(classifySignalCliLogLine("Failed to initialize HTTP Server - oops")).toBe("error");
|
||||
expect(classifySignalCliLogLine('Exception in thread "main"')).toBe("error");
|
||||
});
|
||||
|
||||
it("returns null for empty lines", () => {
|
||||
expect(classifySignalCliLogLine("")).toBe(null);
|
||||
expect(classifySignalCliLogLine(" ")).toBe(null);
|
||||
});
|
||||
});
|
||||
56
extensions/signal/src/probe.ts
Normal file
56
extensions/signal/src/probe.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { BaseProbeResult } from "../../../src/channels/plugins/types.js";
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
|
||||
export type SignalProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
elapsedMs: number;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
function parseSignalVersion(value: unknown): string | null {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const version = (value as { version?: unknown }).version;
|
||||
if (typeof version === "string" && version.trim()) {
|
||||
return version.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function probeSignal(baseUrl: string, timeoutMs: number): Promise<SignalProbe> {
|
||||
const started = Date.now();
|
||||
const result: SignalProbe = {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: null,
|
||||
elapsedMs: 0,
|
||||
version: null,
|
||||
};
|
||||
const check = await signalCheck(baseUrl, timeoutMs);
|
||||
if (!check.ok) {
|
||||
return {
|
||||
...result,
|
||||
status: check.status ?? null,
|
||||
error: check.error ?? "unreachable",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const version = await signalRpcRequest("version", undefined, {
|
||||
baseUrl,
|
||||
timeoutMs,
|
||||
});
|
||||
result.version = parseSignalVersion(version);
|
||||
} catch (err) {
|
||||
result.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
ok: true,
|
||||
status: check.status ?? null,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
34
extensions/signal/src/reaction-level.ts
Normal file
34
extensions/signal/src/reaction-level.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
resolveReactionLevel,
|
||||
type ReactionLevel,
|
||||
type ResolvedReactionLevel,
|
||||
} from "../../../src/utils/reaction-level.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
|
||||
export type SignalReactionLevel = ReactionLevel;
|
||||
export type ResolvedSignalReactionLevel = ResolvedReactionLevel;
|
||||
|
||||
/**
|
||||
* 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: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedSignalReactionLevel {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return resolveReactionLevel({
|
||||
value: account.config.reactionLevel,
|
||||
defaultLevel: "minimal",
|
||||
invalidFallback: "minimal",
|
||||
});
|
||||
}
|
||||
24
extensions/signal/src/rpc-context.ts
Normal file
24
extensions/signal/src/rpc-context.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
|
||||
export function resolveSignalRpcContext(
|
||||
opts: { baseUrl?: string; account?: string; accountId?: string },
|
||||
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 };
|
||||
}
|
||||
65
extensions/signal/src/send-reactions.test.ts
Normal file
65
extensions/signal/src/send-reactions.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js";
|
||||
|
||||
const rpcMock = vi.fn();
|
||||
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/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.mockClear().mockResolvedValue({ timestamp: 123 });
|
||||
});
|
||||
|
||||
it("uses recipients array and targetAuthor for uuid dms", async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
190
extensions/signal/src/send-reactions.ts
Normal file
190
extensions/signal/src/send-reactions.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Signal reactions via signal-cli JSON-RPC API
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../../../src/config/config.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
|
||||
export type SignalReactionOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
targetAuthor?: string;
|
||||
targetAuthorUuid?: string;
|
||||
groupId?: string;
|
||||
};
|
||||
|
||||
export type SignalReactionResult = {
|
||||
ok: boolean;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
type SignalReactionErrorMessages = {
|
||||
missingRecipient: string;
|
||||
invalidTargetTimestamp: string;
|
||||
missingEmoji: string;
|
||||
missingTargetAuthor: string;
|
||||
};
|
||||
|
||||
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 {};
|
||||
}
|
||||
|
||||
async function sendReactionSignalCore(params: {
|
||||
recipient: string;
|
||||
targetTimestamp: number;
|
||||
emoji: string;
|
||||
remove: boolean;
|
||||
opts: SignalReactionOpts;
|
||||
errors: SignalReactionErrorMessages;
|
||||
}): Promise<SignalReactionResult> {
|
||||
const cfg = params.opts.cfg ?? loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: params.opts.accountId,
|
||||
});
|
||||
const { baseUrl, account } = resolveSignalRpcContext(params.opts, accountInfo);
|
||||
|
||||
const normalizedRecipient = normalizeSignalUuid(params.recipient);
|
||||
const groupId = params.opts.groupId?.trim();
|
||||
if (!normalizedRecipient && !groupId) {
|
||||
throw new Error(params.errors.missingRecipient);
|
||||
}
|
||||
if (!Number.isFinite(params.targetTimestamp) || params.targetTimestamp <= 0) {
|
||||
throw new Error(params.errors.invalidTargetTimestamp);
|
||||
}
|
||||
const normalizedEmoji = params.emoji?.trim();
|
||||
if (!normalizedEmoji) {
|
||||
throw new Error(params.errors.missingEmoji);
|
||||
}
|
||||
|
||||
const targetAuthorParams = resolveTargetAuthorParams({
|
||||
targetAuthor: params.opts.targetAuthor,
|
||||
targetAuthorUuid: params.opts.targetAuthorUuid,
|
||||
fallback: normalizedRecipient,
|
||||
});
|
||||
if (groupId && !targetAuthorParams.targetAuthor) {
|
||||
throw new Error(params.errors.missingTargetAuthor);
|
||||
}
|
||||
|
||||
const requestParams: Record<string, unknown> = {
|
||||
emoji: normalizedEmoji,
|
||||
targetTimestamp: params.targetTimestamp,
|
||||
...(params.remove ? { remove: true } : {}),
|
||||
...targetAuthorParams,
|
||||
};
|
||||
if (normalizedRecipient) {
|
||||
requestParams.recipients = [normalizedRecipient];
|
||||
}
|
||||
if (groupId) {
|
||||
requestParams.groupIds = [groupId];
|
||||
}
|
||||
if (account) {
|
||||
requestParams.account = account;
|
||||
}
|
||||
|
||||
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", requestParams, {
|
||||
baseUrl,
|
||||
timeoutMs: params.opts.timeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
timestamp: result?.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return await sendReactionSignalCore({
|
||||
recipient,
|
||||
targetTimestamp,
|
||||
emoji,
|
||||
remove: false,
|
||||
opts,
|
||||
errors: {
|
||||
missingRecipient: "Recipient or groupId is required for Signal reaction",
|
||||
invalidTargetTimestamp: "Valid targetTimestamp is required for Signal reaction",
|
||||
missingEmoji: "Emoji is required for Signal reaction",
|
||||
missingTargetAuthor: "targetAuthor is required for group reactions",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return await sendReactionSignalCore({
|
||||
recipient,
|
||||
targetTimestamp,
|
||||
emoji,
|
||||
remove: true,
|
||||
opts,
|
||||
errors: {
|
||||
missingRecipient: "Recipient or groupId is required for Signal reaction removal",
|
||||
invalidTargetTimestamp: "Valid targetTimestamp is required for Signal reaction removal",
|
||||
missingEmoji: "Emoji is required for Signal reaction removal",
|
||||
missingTargetAuthor: "targetAuthor is required for group reaction removal",
|
||||
},
|
||||
});
|
||||
}
|
||||
249
extensions/signal/src/send.ts
Normal file
249
extensions/signal/src/send.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
|
||||
import { kindFromMime } from "../../../src/media/mime.js";
|
||||
import { resolveOutboundAttachmentFromUrl } from "../../../src/media/outbound-attachment.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
|
||||
export type SignalSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
textMode?: "markdown" | "plain";
|
||||
textStyles?: SignalTextStyleRange[];
|
||||
};
|
||||
|
||||
export type SignalSendResult = {
|
||||
messageId: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
export type SignalRpcOpts = Pick<SignalSendOpts, "baseUrl" | "account" | "accountId" | "timeoutMs">;
|
||||
|
||||
export type SignalReceiptType = "read" | "viewed";
|
||||
|
||||
type SignalTarget =
|
||||
| { type: "recipient"; recipient: string }
|
||||
| { type: "group"; groupId: string }
|
||||
| { type: "username"; username: string };
|
||||
|
||||
function parseTarget(raw: string): SignalTarget {
|
||||
let value = raw.trim();
|
||||
if (!value) {
|
||||
throw new Error("Signal recipient is required");
|
||||
}
|
||||
const lower = value.toLowerCase();
|
||||
if (lower.startsWith("signal:")) {
|
||||
value = value.slice("signal:".length).trim();
|
||||
}
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.startsWith("group:")) {
|
||||
return { type: "group", groupId: value.slice("group:".length).trim() };
|
||||
}
|
||||
if (normalized.startsWith("username:")) {
|
||||
return {
|
||||
type: "username",
|
||||
username: value.slice("username:".length).trim(),
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith("u:")) {
|
||||
return { type: "username", username: value.trim() };
|
||||
}
|
||||
return { type: "recipient", recipient: value };
|
||||
}
|
||||
|
||||
type SignalTargetParams = {
|
||||
recipient?: string[];
|
||||
groupId?: string;
|
||||
username?: string[];
|
||||
};
|
||||
|
||||
type SignalTargetAllowlist = {
|
||||
recipient?: boolean;
|
||||
group?: boolean;
|
||||
username?: boolean;
|
||||
};
|
||||
|
||||
function buildTargetParams(
|
||||
target: SignalTarget,
|
||||
allow: SignalTargetAllowlist,
|
||||
): SignalTargetParams | null {
|
||||
if (target.type === "recipient") {
|
||||
if (!allow.recipient) {
|
||||
return null;
|
||||
}
|
||||
return { recipient: [target.recipient] };
|
||||
}
|
||||
if (target.type === "group") {
|
||||
if (!allow.group) {
|
||||
return null;
|
||||
}
|
||||
return { groupId: target.groupId };
|
||||
}
|
||||
if (target.type === "username") {
|
||||
if (!allow.username) {
|
||||
return null;
|
||||
}
|
||||
return { username: [target.username] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function sendMessageSignal(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: SignalSendOpts = {},
|
||||
): Promise<SignalSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
|
||||
const target = parseTarget(to);
|
||||
let message = text ?? "";
|
||||
let messageFromPlaceholder = false;
|
||||
let textStyles: SignalTextStyleRange[] = [];
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
const maxBytes = (() => {
|
||||
if (typeof opts.maxBytes === "number") {
|
||||
return opts.maxBytes;
|
||||
}
|
||||
if (typeof accountInfo.config.mediaMaxMb === "number") {
|
||||
return accountInfo.config.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
if (typeof cfg.agents?.defaults?.mediaMaxMb === "number") {
|
||||
return cfg.agents.defaults.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
return 8 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
let attachments: string[] | undefined;
|
||||
if (opts.mediaUrl?.trim()) {
|
||||
const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes, {
|
||||
localRoots: opts.mediaLocalRoots,
|
||||
});
|
||||
attachments = [resolved.path];
|
||||
const kind = kindFromMime(resolved.contentType ?? undefined);
|
||||
if (!message && kind) {
|
||||
// Avoid sending an empty body when only attachments exist.
|
||||
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
|
||||
messageFromPlaceholder = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.trim() && !messageFromPlaceholder) {
|
||||
if (textMode === "plain") {
|
||||
textStyles = opts.textStyles ?? [];
|
||||
} else {
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const formatted = markdownToSignalText(message, { tableMode });
|
||||
message = formatted.text;
|
||||
textStyles = formatted.styles;
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.trim() && (!attachments || attachments.length === 0)) {
|
||||
throw new Error("Signal send requires text or media");
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = { message };
|
||||
if (textStyles.length > 0) {
|
||||
params["text-style"] = textStyles.map(
|
||||
(style) => `${style.start}:${style.length}:${style.style}`,
|
||||
);
|
||||
}
|
||||
if (account) {
|
||||
params.account = account;
|
||||
}
|
||||
if (attachments && attachments.length > 0) {
|
||||
params.attachments = attachments;
|
||||
}
|
||||
|
||||
const targetParams = buildTargetParams(target, {
|
||||
recipient: true,
|
||||
group: true,
|
||||
username: true,
|
||||
});
|
||||
if (!targetParams) {
|
||||
throw new Error("Signal recipient is required");
|
||||
}
|
||||
Object.assign(params, targetParams);
|
||||
|
||||
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
|
||||
baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
const timestamp = result?.timestamp;
|
||||
return {
|
||||
messageId: timestamp ? String(timestamp) : "unknown",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendTypingSignal(
|
||||
to: string,
|
||||
opts: SignalRpcOpts & { stop?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts);
|
||||
const targetParams = buildTargetParams(parseTarget(to), {
|
||||
recipient: true,
|
||||
group: true,
|
||||
});
|
||||
if (!targetParams) {
|
||||
return false;
|
||||
}
|
||||
const params: Record<string, unknown> = { ...targetParams };
|
||||
if (account) {
|
||||
params.account = account;
|
||||
}
|
||||
if (opts.stop) {
|
||||
params.stop = true;
|
||||
}
|
||||
await signalRpcRequest("sendTyping", params, {
|
||||
baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function sendReadReceiptSignal(
|
||||
to: string,
|
||||
targetTimestamp: number,
|
||||
opts: SignalRpcOpts & { type?: SignalReceiptType } = {},
|
||||
): Promise<boolean> {
|
||||
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
|
||||
return false;
|
||||
}
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts);
|
||||
const targetParams = buildTargetParams(parseTarget(to), {
|
||||
recipient: true,
|
||||
});
|
||||
if (!targetParams) {
|
||||
return false;
|
||||
}
|
||||
const params: Record<string, unknown> = {
|
||||
...targetParams,
|
||||
targetTimestamp,
|
||||
type: opts.type ?? "read",
|
||||
};
|
||||
if (account) {
|
||||
params.account = account;
|
||||
}
|
||||
await signalRpcRequest("sendReceipt", params, {
|
||||
baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
80
extensions/signal/src/sse-reconnect.ts
Normal file
80
extensions/signal/src/sse-reconnect.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { logVerbose, shouldLogVerbose } from "../../../src/globals.js";
|
||||
import type { BackoffPolicy } from "../../../src/infra/backoff.js";
|
||||
import { computeBackoff, sleepWithAbort } from "../../../src/infra/backoff.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { type SignalSseEvent, streamSignalEvents } from "./client.js";
|
||||
|
||||
const DEFAULT_RECONNECT_POLICY: BackoffPolicy = {
|
||||
initialMs: 1_000,
|
||||
maxMs: 10_000,
|
||||
factor: 2,
|
||||
jitter: 0.2,
|
||||
};
|
||||
|
||||
type RunSignalSseLoopParams = {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
runtime: RuntimeEnv;
|
||||
onEvent: (event: SignalSseEvent) => void;
|
||||
policy?: Partial<BackoffPolicy>;
|
||||
};
|
||||
|
||||
export async function runSignalSseLoop({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal,
|
||||
runtime,
|
||||
onEvent,
|
||||
policy,
|
||||
}: RunSignalSseLoopParams) {
|
||||
const reconnectPolicy = {
|
||||
...DEFAULT_RECONNECT_POLICY,
|
||||
...policy,
|
||||
};
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
const logReconnectVerbose = (message: string) => {
|
||||
if (!shouldLogVerbose()) {
|
||||
return;
|
||||
}
|
||||
logVerbose(message);
|
||||
};
|
||||
|
||||
while (!abortSignal?.aborted) {
|
||||
try {
|
||||
await streamSignalEvents({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal,
|
||||
onEvent: (event) => {
|
||||
reconnectAttempts = 0;
|
||||
onEvent(event);
|
||||
},
|
||||
});
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
reconnectAttempts += 1;
|
||||
const delayMs = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||
logReconnectVerbose(`Signal SSE stream ended, reconnecting in ${delayMs / 1000}s...`);
|
||||
await sleepWithAbort(delayMs, abortSignal);
|
||||
} catch (err) {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
runtime.error?.(`Signal SSE stream error: ${String(err)}`);
|
||||
reconnectAttempts += 1;
|
||||
const delayMs = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||
runtime.log?.(`Signal SSE connection lost, reconnecting in ${delayMs / 1000}s...`);
|
||||
try {
|
||||
await sleepWithAbort(delayMs, abortSignal);
|
||||
} catch (sleepErr) {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
throw sleepErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,2 @@
|
||||
import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SignalAccountConfig } from "../config/types.js";
|
||||
import { resolveAccountEntry } from "../routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
|
||||
export type ResolvedSignalAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
configured: boolean;
|
||||
config: SignalAccountConfig;
|
||||
};
|
||||
|
||||
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("signal");
|
||||
export const listSignalAccountIds = listAccountIds;
|
||||
export const resolveDefaultSignalAccountId = resolveDefaultAccountId;
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): SignalAccountConfig | undefined {
|
||||
return resolveAccountEntry(cfg.channels?.signal?.accounts, accountId);
|
||||
}
|
||||
|
||||
function mergeSignalAccountConfig(cfg: OpenClawConfig, accountId: string): SignalAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.signal ?? {}) as SignalAccountConfig & {
|
||||
accounts?: unknown;
|
||||
};
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveSignalAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedSignalAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.channels?.signal?.enabled !== false;
|
||||
const merged = mergeSignalAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const host = merged.httpHost?.trim() || "127.0.0.1";
|
||||
const port = merged.httpPort ?? 8080;
|
||||
const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`;
|
||||
const configured = Boolean(
|
||||
merged.account?.trim() ||
|
||||
merged.httpUrl?.trim() ||
|
||||
merged.cliPath?.trim() ||
|
||||
merged.httpHost?.trim() ||
|
||||
typeof merged.httpPort === "number" ||
|
||||
typeof merged.autoStart === "boolean",
|
||||
);
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
baseUrl,
|
||||
configured,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledSignalAccounts(cfg: OpenClawConfig): ResolvedSignalAccount[] {
|
||||
return listSignalAccountIds(cfg)
|
||||
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/accounts
|
||||
export * from "../../extensions/signal/src/accounts.js";
|
||||
|
||||
@@ -1,67 +1,2 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWithTimeoutMock = vi.fn();
|
||||
const resolveFetchMock = vi.fn();
|
||||
|
||||
vi.mock("../infra/fetch.js", () => ({
|
||||
resolveFetch: (...args: unknown[]) => resolveFetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/secure-random.js", () => ({
|
||||
generateSecureUuid: () => "test-id",
|
||||
}));
|
||||
|
||||
vi.mock("../utils/fetch-timeout.js", () => ({
|
||||
fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
|
||||
function rpcResponse(body: unknown, status = 200): Response {
|
||||
if (typeof body === "string") {
|
||||
return new Response(body, { status });
|
||||
}
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}
|
||||
|
||||
describe("signalRpcRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveFetchMock.mockReturnValue(vi.fn());
|
||||
});
|
||||
|
||||
it("returns parsed RPC result", async () => {
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(
|
||||
rpcResponse({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" }),
|
||||
);
|
||||
|
||||
const result = await signalRpcRequest<{ version: string }>("version", undefined, {
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ version: "0.13.22" });
|
||||
});
|
||||
|
||||
it("throws a wrapped error when RPC response JSON is malformed", async () => {
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse("not-json", 502));
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: "Signal RPC returned malformed JSON (status 502)",
|
||||
cause: expect.any(SyntaxError),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when RPC response envelope has neither result nor error", async () => {
|
||||
fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse({ jsonrpc: "2.0", id: "test-id" }));
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
}),
|
||||
).rejects.toThrow("Signal RPC returned invalid response envelope (status 200)");
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/client.test
|
||||
export * from "../../extensions/signal/src/client.test.js";
|
||||
|
||||
@@ -1,215 +1,2 @@
|
||||
import { resolveFetch } from "../infra/fetch.js";
|
||||
import { generateSecureUuid } from "../infra/secure-random.js";
|
||||
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
|
||||
|
||||
export type SignalRpcOptions = {
|
||||
baseUrl: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type SignalRpcError = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type SignalRpcResponse<T> = {
|
||||
jsonrpc?: string;
|
||||
result?: T;
|
||||
error?: SignalRpcError;
|
||||
id?: string | number | null;
|
||||
};
|
||||
|
||||
export type SignalSseEvent = {
|
||||
event?: string;
|
||||
data?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Signal base URL is required");
|
||||
}
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
return `http://${trimmed}`.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function getRequiredFetch(): typeof fetch {
|
||||
const fetchImpl = resolveFetch();
|
||||
if (!fetchImpl) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
return fetchImpl;
|
||||
}
|
||||
|
||||
function parseSignalRpcResponse<T>(text: string, status: number): SignalRpcResponse<T> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw new Error(`Signal RPC returned malformed JSON (status ${status})`, { cause: err });
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error(`Signal RPC returned invalid response envelope (status ${status})`);
|
||||
}
|
||||
|
||||
const rpc = parsed as SignalRpcResponse<T>;
|
||||
const hasResult = Object.hasOwn(rpc, "result");
|
||||
if (!rpc.error && !hasResult) {
|
||||
throw new Error(`Signal RPC returned invalid response envelope (status ${status})`);
|
||||
}
|
||||
return rpc;
|
||||
}
|
||||
|
||||
export async function signalRpcRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
opts: SignalRpcOptions,
|
||||
): Promise<T> {
|
||||
const baseUrl = normalizeBaseUrl(opts.baseUrl);
|
||||
const id = generateSecureUuid();
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params,
|
||||
id,
|
||||
});
|
||||
const res = await fetchWithTimeout(
|
||||
`${baseUrl}/api/v1/rpc`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
},
|
||||
opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
getRequiredFetch(),
|
||||
);
|
||||
if (res.status === 201) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
throw new Error(`Signal RPC empty response (status ${res.status})`);
|
||||
}
|
||||
const parsed = parseSignalRpcResponse<T>(text, res.status);
|
||||
if (parsed.error) {
|
||||
const code = parsed.error.code ?? "unknown";
|
||||
const msg = parsed.error.message ?? "Signal RPC error";
|
||||
throw new Error(`Signal RPC ${code}: ${msg}`);
|
||||
}
|
||||
return parsed.result as T;
|
||||
}
|
||||
|
||||
export async function signalCheck(
|
||||
baseUrl: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${normalized}/api/v1/check`,
|
||||
{ method: "GET" },
|
||||
timeoutMs,
|
||||
getRequiredFetch(),
|
||||
);
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, status: res.status, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamSignalEvents(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
onEvent: (event: SignalSseEvent) => void;
|
||||
}): Promise<void> {
|
||||
const baseUrl = normalizeBaseUrl(params.baseUrl);
|
||||
const url = new URL(`${baseUrl}/api/v1/events`);
|
||||
if (params.account) {
|
||||
url.searchParams.set("account", params.account);
|
||||
}
|
||||
|
||||
const fetchImpl = resolveFetch();
|
||||
if (!fetchImpl) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
const res = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: params.abortSignal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Signal SSE failed (${res.status} ${res.statusText || "error"})`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let currentEvent: SignalSseEvent = {};
|
||||
|
||||
const flushEvent = () => {
|
||||
if (!currentEvent.data && !currentEvent.event && !currentEvent.id) {
|
||||
return;
|
||||
}
|
||||
params.onEvent({
|
||||
event: currentEvent.event,
|
||||
data: currentEvent.data,
|
||||
id: currentEvent.id,
|
||||
});
|
||||
currentEvent = {};
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let lineEnd = buffer.indexOf("\n");
|
||||
while (lineEnd !== -1) {
|
||||
let line = buffer.slice(0, lineEnd);
|
||||
buffer = buffer.slice(lineEnd + 1);
|
||||
if (line.endsWith("\r")) {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
|
||||
if (line === "") {
|
||||
flushEvent();
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(":")) {
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
continue;
|
||||
}
|
||||
const [rawField, ...rest] = line.split(":");
|
||||
const field = rawField.trim();
|
||||
const rawValue = rest.join(":");
|
||||
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
|
||||
if (field === "event") {
|
||||
currentEvent.event = value;
|
||||
} else if (field === "data") {
|
||||
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${value}` : value;
|
||||
} else if (field === "id") {
|
||||
currentEvent.id = value;
|
||||
}
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
flushEvent();
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/client
|
||||
export * from "../../extensions/signal/src/client.js";
|
||||
|
||||
@@ -1,147 +1,2 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export type SignalDaemonOpts = {
|
||||
cliPath: string;
|
||||
account?: string;
|
||||
httpHost: string;
|
||||
httpPort: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
export type SignalDaemonHandle = {
|
||||
pid?: number;
|
||||
stop: () => void;
|
||||
exited: Promise<SignalDaemonExitEvent>;
|
||||
isExited: () => boolean;
|
||||
};
|
||||
|
||||
export type SignalDaemonExitEvent = {
|
||||
source: "process" | "spawn-error";
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
export function formatSignalDaemonExit(exit: SignalDaemonExitEvent): string {
|
||||
return `signal daemon exited (source=${exit.source} code=${String(exit.code ?? "null")} signal=${String(exit.signal ?? "null")})`;
|
||||
}
|
||||
|
||||
export function classifySignalCliLogLine(line: string): "log" | "error" | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
// signal-cli commonly writes all logs to stderr; treat severity explicitly.
|
||||
if (/\b(ERROR|WARN|WARNING)\b/.test(trimmed)) {
|
||||
return "error";
|
||||
}
|
||||
// Some signal-cli failures are not tagged with WARN/ERROR but should still be surfaced loudly.
|
||||
if (/\b(FAILED|SEVERE|EXCEPTION)\b/i.test(trimmed)) {
|
||||
return "error";
|
||||
}
|
||||
return "log";
|
||||
}
|
||||
|
||||
function bindSignalCliOutput(params: {
|
||||
stream: NodeJS.ReadableStream | null | undefined;
|
||||
log: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}): void {
|
||||
params.stream?.on("data", (data) => {
|
||||
for (const line of data.toString().split(/\r?\n/)) {
|
||||
const kind = classifySignalCliLogLine(line);
|
||||
if (kind === "log") {
|
||||
params.log(`signal-cli: ${line.trim()}`);
|
||||
} else if (kind === "error") {
|
||||
params.error(`signal-cli: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildDaemonArgs(opts: SignalDaemonOpts): string[] {
|
||||
const args: string[] = [];
|
||||
if (opts.account) {
|
||||
args.push("-a", opts.account);
|
||||
}
|
||||
args.push("daemon");
|
||||
args.push("--http", `${opts.httpHost}:${opts.httpPort}`);
|
||||
args.push("--no-receive-stdout");
|
||||
|
||||
if (opts.receiveMode) {
|
||||
args.push("--receive-mode", opts.receiveMode);
|
||||
}
|
||||
if (opts.ignoreAttachments) {
|
||||
args.push("--ignore-attachments");
|
||||
}
|
||||
if (opts.ignoreStories) {
|
||||
args.push("--ignore-stories");
|
||||
}
|
||||
if (opts.sendReadReceipts) {
|
||||
args.push("--send-read-receipts");
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
|
||||
const args = buildDaemonArgs(opts);
|
||||
const child = spawn(opts.cliPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const log = opts.runtime?.log ?? (() => {});
|
||||
const error = opts.runtime?.error ?? (() => {});
|
||||
let exited = false;
|
||||
let settledExit = false;
|
||||
let resolveExit!: (value: SignalDaemonExitEvent) => void;
|
||||
const exitedPromise = new Promise<SignalDaemonExitEvent>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const settleExit = (value: SignalDaemonExitEvent) => {
|
||||
if (settledExit) {
|
||||
return;
|
||||
}
|
||||
settledExit = true;
|
||||
exited = true;
|
||||
resolveExit(value);
|
||||
};
|
||||
|
||||
bindSignalCliOutput({ stream: child.stdout, log, error });
|
||||
bindSignalCliOutput({ stream: child.stderr, log, error });
|
||||
child.once("exit", (code, signal) => {
|
||||
settleExit({
|
||||
source: "process",
|
||||
code: typeof code === "number" ? code : null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
error(
|
||||
formatSignalDaemonExit({ source: "process", code: code ?? null, signal: signal ?? null }),
|
||||
);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
settleExit({
|
||||
source: "process",
|
||||
code: typeof code === "number" ? code : null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
error(`signal-cli spawn error: ${String(err)}`);
|
||||
settleExit({ source: "spawn-error", code: null, signal: null });
|
||||
});
|
||||
|
||||
return {
|
||||
pid: child.pid ?? undefined,
|
||||
exited: exitedPromise,
|
||||
isExited: () => exited,
|
||||
stop: () => {
|
||||
if (!child.killed && !exited) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/daemon
|
||||
export * from "../../extensions/signal/src/daemon.js";
|
||||
|
||||
@@ -1,388 +1,2 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalTextChunks } from "./format.js";
|
||||
|
||||
function expectChunkStyleRangesInBounds(chunks: ReturnType<typeof markdownToSignalTextChunks>) {
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
expect(style.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("splitSignalFormattedText", () => {
|
||||
// We test the internal chunking behavior via markdownToSignalTextChunks with
|
||||
// pre-rendered SignalFormattedText. The helper is not exported, so we test
|
||||
// it indirectly through integration tests and by constructing scenarios that
|
||||
// exercise the splitting logic.
|
||||
|
||||
describe("style-aware splitting - basic text", () => {
|
||||
it("text with no styles splits correctly at whitespace", () => {
|
||||
// Create text that exceeds limit and must be split
|
||||
const limit = 20;
|
||||
const markdown = "hello world this is a test";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
// Verify all text is preserved (joined chunks should contain all words)
|
||||
const joinedText = chunks.map((c) => c.text).join(" ");
|
||||
expect(joinedText).toContain("hello");
|
||||
expect(joinedText).toContain("world");
|
||||
expect(joinedText).toContain("test");
|
||||
});
|
||||
|
||||
it("empty text returns empty array", () => {
|
||||
// Empty input produces no chunks (not an empty chunk)
|
||||
const chunks = markdownToSignalTextChunks("", 100);
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
|
||||
it("text under limit returns single chunk unchanged", () => {
|
||||
const markdown = "short text";
|
||||
const chunks = markdownToSignalTextChunks(markdown, 100);
|
||||
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0].text).toBe("short text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("style-aware splitting - style preservation", () => {
|
||||
it("style fully within first chunk stays in first chunk", () => {
|
||||
// Create a message where bold text is in the first chunk
|
||||
const limit = 30;
|
||||
const markdown = "**bold** word more words here that exceed limit";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
// First chunk should contain the bold style
|
||||
const firstChunk = chunks[0];
|
||||
expect(firstChunk.text).toContain("bold");
|
||||
expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true);
|
||||
// The bold style should start at position 0 in the first chunk
|
||||
const boldStyle = firstChunk.styles.find((s) => s.style === "BOLD");
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(boldStyle!.start).toBe(0);
|
||||
expect(boldStyle!.length).toBe(4); // "bold"
|
||||
});
|
||||
|
||||
it("style fully within second chunk has offset adjusted to chunk-local position", () => {
|
||||
// Create a message where the styled text is in the second chunk
|
||||
const limit = 30;
|
||||
const markdown = "some filler text here **bold** at the end";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
// Find the chunk containing "bold"
|
||||
const chunkWithBold = chunks.find((c) => c.text.includes("bold"));
|
||||
expect(chunkWithBold).toBeDefined();
|
||||
expect(chunkWithBold!.styles.some((s) => s.style === "BOLD")).toBe(true);
|
||||
|
||||
// The bold style should have chunk-local offset (not original text offset)
|
||||
const boldStyle = chunkWithBold!.styles.find((s) => s.style === "BOLD");
|
||||
expect(boldStyle).toBeDefined();
|
||||
// The offset should be the position within this chunk, not the original text
|
||||
const boldPos = chunkWithBold!.text.indexOf("bold");
|
||||
expect(boldStyle!.start).toBe(boldPos);
|
||||
expect(boldStyle!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("style spanning chunk boundary is split into two ranges", () => {
|
||||
// Create text where a styled span crosses the chunk boundary
|
||||
const limit = 15;
|
||||
// "hello **bold text here** end" - the bold spans across chunk boundary
|
||||
const markdown = "hello **boldtexthere** end";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Both chunks should have BOLD styles if the span was split
|
||||
const chunksWithBold = chunks.filter((c) => c.styles.some((s) => s.style === "BOLD"));
|
||||
// At least one chunk should have the bold style
|
||||
expect(chunksWithBold.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// For each chunk with bold, verify the style range is valid for that chunk
|
||||
for (const chunk of chunksWithBold) {
|
||||
for (const style of chunk.styles.filter((s) => s.style === "BOLD")) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("style starting exactly at split point goes entirely to second chunk", () => {
|
||||
// Create text where style starts right at where we'd split
|
||||
const limit = 10;
|
||||
const markdown = "abcdefghi **bold**";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Find chunk with bold
|
||||
const chunkWithBold = chunks.find((c) => c.styles.some((s) => s.style === "BOLD"));
|
||||
expect(chunkWithBold).toBeDefined();
|
||||
|
||||
// Verify the bold style is valid within its chunk
|
||||
const boldStyle = chunkWithBold!.styles.find((s) => s.style === "BOLD");
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(boldStyle!.start).toBeGreaterThanOrEqual(0);
|
||||
expect(boldStyle!.start + boldStyle!.length).toBeLessThanOrEqual(chunkWithBold!.text.length);
|
||||
});
|
||||
|
||||
it("style ending exactly at split point stays entirely in first chunk", () => {
|
||||
const limit = 10;
|
||||
const markdown = "**bold** rest of text";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// First chunk should have the complete bold style
|
||||
const firstChunk = chunks[0];
|
||||
if (firstChunk.text.includes("bold")) {
|
||||
const boldStyle = firstChunk.styles.find((s) => s.style === "BOLD");
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(boldStyle!.start + boldStyle!.length).toBeLessThanOrEqual(firstChunk.text.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("multiple styles, some spanning boundary, some not", () => {
|
||||
const limit = 25;
|
||||
// Mix of styles: italic at start, bold spanning boundary, monospace at end
|
||||
const markdown = "_italic_ some text **bold text** and `code`";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Verify all style ranges are valid within their respective chunks
|
||||
expectChunkStyleRangesInBounds(chunks);
|
||||
|
||||
// Collect all styles across chunks
|
||||
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
|
||||
// We should have at least italic, bold, and monospace somewhere
|
||||
expect(allStyles).toContain("ITALIC");
|
||||
expect(allStyles).toContain("BOLD");
|
||||
expect(allStyles).toContain("MONOSPACE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("style-aware splitting - edge cases", () => {
|
||||
it("handles zero-length text with styles gracefully", () => {
|
||||
// Edge case: empty markdown produces no chunks
|
||||
const chunks = markdownToSignalTextChunks("", 100);
|
||||
expect(chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles text that splits exactly at limit", () => {
|
||||
const limit = 10;
|
||||
const markdown = "1234567890"; // exactly 10 chars
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0].text).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("preserves style through whitespace trimming", () => {
|
||||
const limit = 30;
|
||||
const markdown = "**bold** some text that is longer than limit";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// Bold should be preserved in first chunk
|
||||
const firstChunk = chunks[0];
|
||||
if (firstChunk.text.includes("bold")) {
|
||||
expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles repeated substrings correctly (no indexOf fragility)", () => {
|
||||
// This test exposes the fragility of using indexOf to find chunk positions.
|
||||
// If the same substring appears multiple times, indexOf finds the first
|
||||
// occurrence, not necessarily the correct one.
|
||||
const limit = 20;
|
||||
// "word" appears multiple times - indexOf("word") would always find first
|
||||
const markdown = "word **bold word** word more text here to chunk";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// Verify chunks are under limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// Find chunk(s) with bold style
|
||||
const chunksWithBold = chunks.filter((c) => c.styles.some((s) => s.style === "BOLD"));
|
||||
expect(chunksWithBold.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The bold style should correctly cover "bold word" (or part of it if split)
|
||||
// and NOT incorrectly point to the first "word" in the text
|
||||
for (const chunk of chunksWithBold) {
|
||||
for (const style of chunk.styles.filter((s) => s.style === "BOLD")) {
|
||||
const styledText = chunk.text.slice(style.start, style.start + style.length);
|
||||
// The styled text should be part of "bold word", not the initial "word"
|
||||
expect(styledText).toMatch(/^(bold( word)?|word)$/);
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("handles chunk that starts with whitespace after split", () => {
|
||||
// When text is split at whitespace, the next chunk might have leading
|
||||
// whitespace trimmed. Styles must account for this.
|
||||
const limit = 15;
|
||||
const markdown = "some text **bold** at end";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// All style ranges must be valid
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("deterministically tracks position without indexOf fragility", () => {
|
||||
// This test ensures the chunker doesn't rely on finding chunks via indexOf
|
||||
// which can fail when chunkText trims whitespace or when duplicates exist.
|
||||
// Create text with lots of whitespace and repeated patterns.
|
||||
const limit = 25;
|
||||
const markdown = "aaa **bold** aaa **bold** aaa extra text to force split";
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// Multiple chunks expected
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// All chunks should respect limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// All style ranges must be valid within their chunks
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
// The styled text at that position should actually be "bold"
|
||||
if (style.style === "BOLD") {
|
||||
const styledText = chunk.text.slice(style.start, style.start + style.length);
|
||||
expect(styledText).toBe("bold");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToSignalTextChunks", () => {
|
||||
describe("link expansion chunk limit", () => {
|
||||
it("does not exceed chunk limit after link expansion", () => {
|
||||
// Create text that is close to limit, with a link that will expand
|
||||
const limit = 100;
|
||||
// Create text that's 90 chars, leaving only 10 chars of headroom
|
||||
const filler = "x".repeat(80);
|
||||
// This link will expand from "[link](url)" to "link (https://example.com/very/long/path)"
|
||||
const markdown = `${filler} [link](https://example.com/very/long/path/that/will/exceed/limit)`;
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles multiple links near chunk boundary", () => {
|
||||
const limit = 100;
|
||||
const filler = "x".repeat(60);
|
||||
const markdown = `${filler} [a](https://a.com) [b](https://b.com) [c](https://c.com)`;
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("link expansion with style preservation", () => {
|
||||
it("long message with links that expand beyond limit preserves all text", () => {
|
||||
const limit = 80;
|
||||
const filler = "a".repeat(50);
|
||||
const markdown = `${filler} [click here](https://example.com/very/long/path/to/page) more text`;
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// All chunks should be under limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// Combined text should contain all original content
|
||||
const combined = chunks.map((c) => c.text).join("");
|
||||
expect(combined).toContain(filler);
|
||||
expect(combined).toContain("click here");
|
||||
expect(combined).toContain("example.com");
|
||||
});
|
||||
|
||||
it("styles (bold, italic) survive chunking correctly after link expansion", () => {
|
||||
const limit = 60;
|
||||
const markdown =
|
||||
"**bold start** text [link](https://example.com/path) _italic_ more content here to force chunking";
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// Should have multiple chunks
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// All style ranges should be valid within their chunks
|
||||
expectChunkStyleRangesInBounds(chunks);
|
||||
|
||||
// Verify styles exist somewhere
|
||||
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
|
||||
expect(allStyles).toContain("BOLD");
|
||||
expect(allStyles).toContain("ITALIC");
|
||||
});
|
||||
|
||||
it("multiple links near chunk boundary all get properly chunked", () => {
|
||||
const limit = 50;
|
||||
const markdown =
|
||||
"[first](https://first.com/long/path) [second](https://second.com/another/path) [third](https://third.com)";
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// All chunks should respect limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// All link labels should appear somewhere
|
||||
const combined = chunks.map((c) => c.text).join("");
|
||||
expect(combined).toContain("first");
|
||||
expect(combined).toContain("second");
|
||||
expect(combined).toContain("third");
|
||||
});
|
||||
|
||||
it("preserves spoiler style through link expansion and chunking", () => {
|
||||
const limit = 40;
|
||||
const markdown =
|
||||
"||secret content|| and [link](https://example.com/path) with more text to chunk";
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, limit);
|
||||
|
||||
// All chunks should respect limit
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.text.length).toBeLessThanOrEqual(limit);
|
||||
}
|
||||
|
||||
// Spoiler style should exist and be valid
|
||||
const chunkWithSpoiler = chunks.find((c) => c.styles.some((s) => s.style === "SPOILER"));
|
||||
expect(chunkWithSpoiler).toBeDefined();
|
||||
|
||||
const spoilerStyle = chunkWithSpoiler!.styles.find((s) => s.style === "SPOILER");
|
||||
expect(spoilerStyle).toBeDefined();
|
||||
expect(spoilerStyle!.start).toBeGreaterThanOrEqual(0);
|
||||
expect(spoilerStyle!.start + spoilerStyle!.length).toBeLessThanOrEqual(
|
||||
chunkWithSpoiler!.text.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/format.chunking.test
|
||||
export * from "../../extensions/signal/src/format.chunking.test.js";
|
||||
|
||||
@@ -1,35 +1,2 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalText } from "./format.js";
|
||||
|
||||
describe("markdownToSignalText", () => {
|
||||
describe("duplicate URL display", () => {
|
||||
it("does not duplicate URL for normalized equivalent labels", () => {
|
||||
const equivalentCases = [
|
||||
{ input: "[selfh.st](http://selfh.st)", expected: "selfh.st" },
|
||||
{ input: "[example.com](https://example.com)", expected: "example.com" },
|
||||
{ input: "[www.example.com](https://example.com)", expected: "www.example.com" },
|
||||
{ input: "[example.com](https://example.com/)", expected: "example.com" },
|
||||
{ input: "[example.com](https://example.com///)", expected: "example.com" },
|
||||
{ input: "[example.com](https://www.example.com)", expected: "example.com" },
|
||||
{ input: "[EXAMPLE.COM](https://example.com)", expected: "EXAMPLE.COM" },
|
||||
{ input: "[example.com/page](https://example.com/page)", expected: "example.com/page" },
|
||||
] as const;
|
||||
|
||||
for (const { input, expected } of equivalentCases) {
|
||||
const res = markdownToSignalText(input);
|
||||
expect(res.text).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("still shows URL when label is meaningfully different", () => {
|
||||
const res = markdownToSignalText("[click here](https://example.com)");
|
||||
expect(res.text).toBe("click here (https://example.com)");
|
||||
});
|
||||
|
||||
it("handles URL with path - should show URL when label is just domain", () => {
|
||||
// Label is just domain, URL has path - these are meaningfully different
|
||||
const res = markdownToSignalText("[example.com](https://example.com/page)");
|
||||
expect(res.text).toBe("example.com (https://example.com/page)");
|
||||
});
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/format.links.test
|
||||
export * from "../../extensions/signal/src/format.links.test.js";
|
||||
|
||||
@@ -1,68 +1,2 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalText } from "./format.js";
|
||||
|
||||
describe("markdownToSignalText", () => {
|
||||
it("renders inline styles", () => {
|
||||
const res = markdownToSignalText("hi _there_ **boss** ~~nope~~ `code`");
|
||||
|
||||
expect(res.text).toBe("hi there boss nope code");
|
||||
expect(res.styles).toEqual([
|
||||
{ start: 3, length: 5, style: "ITALIC" },
|
||||
{ start: 9, length: 4, style: "BOLD" },
|
||||
{ start: 14, length: 4, style: "STRIKETHROUGH" },
|
||||
{ start: 19, length: 4, style: "MONOSPACE" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders links as label plus url when needed", () => {
|
||||
const res = markdownToSignalText("see [docs](https://example.com) and https://example.com");
|
||||
|
||||
expect(res.text).toBe("see docs (https://example.com) and https://example.com");
|
||||
expect(res.styles).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps style offsets correct with multiple expanded links", () => {
|
||||
const markdown =
|
||||
"[first](https://example.com/first) **bold** [second](https://example.com/second)";
|
||||
const res = markdownToSignalText(markdown);
|
||||
|
||||
const expectedText =
|
||||
"first (https://example.com/first) bold second (https://example.com/second)";
|
||||
|
||||
expect(res.text).toBe(expectedText);
|
||||
expect(res.styles).toEqual([{ start: expectedText.indexOf("bold"), length: 4, style: "BOLD" }]);
|
||||
});
|
||||
|
||||
it("applies spoiler styling", () => {
|
||||
const res = markdownToSignalText("hello ||secret|| world");
|
||||
|
||||
expect(res.text).toBe("hello secret world");
|
||||
expect(res.styles).toEqual([{ start: 6, length: 6, style: "SPOILER" }]);
|
||||
});
|
||||
|
||||
it("renders fenced code blocks with monospaced styles", () => {
|
||||
const res = markdownToSignalText("before\n\n```\nconst x = 1;\n```\n\nafter");
|
||||
|
||||
const prefix = "before\n\n";
|
||||
const code = "const x = 1;\n";
|
||||
const suffix = "\nafter";
|
||||
|
||||
expect(res.text).toBe(`${prefix}${code}${suffix}`);
|
||||
expect(res.styles).toEqual([{ start: prefix.length, length: code.length, style: "MONOSPACE" }]);
|
||||
});
|
||||
|
||||
it("renders lists without extra block markup", () => {
|
||||
const res = markdownToSignalText("- one\n- two");
|
||||
|
||||
expect(res.text).toBe("• one\n• two");
|
||||
expect(res.styles).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses UTF-16 code units for offsets", () => {
|
||||
const res = markdownToSignalText("😀 **bold**");
|
||||
|
||||
const prefix = "😀 ";
|
||||
expect(res.text).toBe(`${prefix}bold`);
|
||||
expect(res.styles).toEqual([{ start: prefix.length, length: 4, style: "BOLD" }]);
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/format.test
|
||||
export * from "../../extensions/signal/src/format.test.js";
|
||||
|
||||
@@ -1,397 +1,2 @@
|
||||
import type { MarkdownTableMode } from "../config/types.base.js";
|
||||
import {
|
||||
chunkMarkdownIR,
|
||||
markdownToIR,
|
||||
type MarkdownIR,
|
||||
type MarkdownStyle,
|
||||
} from "../markdown/ir.js";
|
||||
|
||||
type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER";
|
||||
|
||||
export type SignalTextStyleRange = {
|
||||
start: number;
|
||||
length: number;
|
||||
style: SignalTextStyle;
|
||||
};
|
||||
|
||||
export type SignalFormattedText = {
|
||||
text: string;
|
||||
styles: SignalTextStyleRange[];
|
||||
};
|
||||
|
||||
type SignalMarkdownOptions = {
|
||||
tableMode?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
type SignalStyleSpan = {
|
||||
start: number;
|
||||
end: number;
|
||||
style: SignalTextStyle;
|
||||
};
|
||||
|
||||
type Insertion = {
|
||||
pos: number;
|
||||
length: number;
|
||||
};
|
||||
|
||||
function normalizeUrlForComparison(url: string): string {
|
||||
let normalized = url.toLowerCase();
|
||||
// Strip protocol
|
||||
normalized = normalized.replace(/^https?:\/\//, "");
|
||||
// Strip www. prefix
|
||||
normalized = normalized.replace(/^www\./, "");
|
||||
// Strip trailing slashes
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapStyle(style: MarkdownStyle): SignalTextStyle | null {
|
||||
switch (style) {
|
||||
case "bold":
|
||||
return "BOLD";
|
||||
case "italic":
|
||||
return "ITALIC";
|
||||
case "strikethrough":
|
||||
return "STRIKETHROUGH";
|
||||
case "code":
|
||||
case "code_block":
|
||||
return "MONOSPACE";
|
||||
case "spoiler":
|
||||
return "SPOILER";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeStyles(styles: SignalTextStyleRange[]): SignalTextStyleRange[] {
|
||||
const sorted = [...styles].toSorted((a, b) => {
|
||||
if (a.start !== b.start) {
|
||||
return a.start - b.start;
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return a.length - b.length;
|
||||
}
|
||||
return a.style.localeCompare(b.style);
|
||||
});
|
||||
|
||||
const merged: SignalTextStyleRange[] = [];
|
||||
for (const style of sorted) {
|
||||
const prev = merged[merged.length - 1];
|
||||
if (prev && prev.style === style.style && style.start <= prev.start + prev.length) {
|
||||
const prevEnd = prev.start + prev.length;
|
||||
const nextEnd = Math.max(prevEnd, style.start + style.length);
|
||||
prev.length = nextEnd - prev.start;
|
||||
continue;
|
||||
}
|
||||
merged.push({ ...style });
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function clampStyles(styles: SignalTextStyleRange[], maxLength: number): SignalTextStyleRange[] {
|
||||
const clamped: SignalTextStyleRange[] = [];
|
||||
for (const style of styles) {
|
||||
const start = Math.max(0, Math.min(style.start, maxLength));
|
||||
const end = Math.min(style.start + style.length, maxLength);
|
||||
const length = end - start;
|
||||
if (length > 0) {
|
||||
clamped.push({ start, length, style: style.style });
|
||||
}
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function applyInsertionsToStyles(
|
||||
spans: SignalStyleSpan[],
|
||||
insertions: Insertion[],
|
||||
): SignalStyleSpan[] {
|
||||
if (insertions.length === 0) {
|
||||
return spans;
|
||||
}
|
||||
const sortedInsertions = [...insertions].toSorted((a, b) => a.pos - b.pos);
|
||||
let updated = spans;
|
||||
let cumulativeShift = 0;
|
||||
|
||||
for (const insertion of sortedInsertions) {
|
||||
const insertionPos = insertion.pos + cumulativeShift;
|
||||
const next: SignalStyleSpan[] = [];
|
||||
for (const span of updated) {
|
||||
if (span.end <= insertionPos) {
|
||||
next.push(span);
|
||||
continue;
|
||||
}
|
||||
if (span.start >= insertionPos) {
|
||||
next.push({
|
||||
start: span.start + insertion.length,
|
||||
end: span.end + insertion.length,
|
||||
style: span.style,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (span.start < insertionPos && span.end > insertionPos) {
|
||||
if (insertionPos > span.start) {
|
||||
next.push({
|
||||
start: span.start,
|
||||
end: insertionPos,
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
const shiftedStart = insertionPos + insertion.length;
|
||||
const shiftedEnd = span.end + insertion.length;
|
||||
if (shiftedEnd > shiftedStart) {
|
||||
next.push({
|
||||
start: shiftedStart,
|
||||
end: shiftedEnd,
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
updated = next;
|
||||
cumulativeShift += insertion.length;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
function renderSignalText(ir: MarkdownIR): SignalFormattedText {
|
||||
const text = ir.text ?? "";
|
||||
if (!text) {
|
||||
return { text: "", styles: [] };
|
||||
}
|
||||
|
||||
const sortedLinks = [...ir.links].toSorted((a, b) => a.start - b.start);
|
||||
let out = "";
|
||||
let cursor = 0;
|
||||
const insertions: Insertion[] = [];
|
||||
|
||||
for (const link of sortedLinks) {
|
||||
if (link.start < cursor) {
|
||||
continue;
|
||||
}
|
||||
out += text.slice(cursor, link.end);
|
||||
|
||||
const href = link.href.trim();
|
||||
const label = text.slice(link.start, link.end);
|
||||
const trimmedLabel = label.trim();
|
||||
|
||||
if (href) {
|
||||
if (!trimmedLabel) {
|
||||
out += href;
|
||||
insertions.push({ pos: link.end, length: href.length });
|
||||
} else {
|
||||
// Check if label is similar enough to URL that showing both would be redundant
|
||||
const normalizedLabel = normalizeUrlForComparison(trimmedLabel);
|
||||
let comparableHref = href;
|
||||
if (href.startsWith("mailto:")) {
|
||||
comparableHref = href.slice("mailto:".length);
|
||||
}
|
||||
const normalizedHref = normalizeUrlForComparison(comparableHref);
|
||||
|
||||
// Only show URL if label is meaningfully different from it
|
||||
if (normalizedLabel !== normalizedHref) {
|
||||
const addition = ` (${href})`;
|
||||
out += addition;
|
||||
insertions.push({ pos: link.end, length: addition.length });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cursor = link.end;
|
||||
}
|
||||
|
||||
out += text.slice(cursor);
|
||||
|
||||
const mappedStyles: SignalStyleSpan[] = ir.styles
|
||||
.map((span) => {
|
||||
const mapped = mapStyle(span.style);
|
||||
if (!mapped) {
|
||||
return null;
|
||||
}
|
||||
return { start: span.start, end: span.end, style: mapped };
|
||||
})
|
||||
.filter((span): span is SignalStyleSpan => span !== null);
|
||||
|
||||
const adjusted = applyInsertionsToStyles(mappedStyles, insertions);
|
||||
const trimmedText = out.trimEnd();
|
||||
const trimmedLength = trimmedText.length;
|
||||
const clamped = clampStyles(
|
||||
adjusted.map((span) => ({
|
||||
start: span.start,
|
||||
length: span.end - span.start,
|
||||
style: span.style,
|
||||
})),
|
||||
trimmedLength,
|
||||
);
|
||||
|
||||
return {
|
||||
text: trimmedText,
|
||||
styles: mergeStyles(clamped),
|
||||
};
|
||||
}
|
||||
|
||||
export function markdownToSignalText(
|
||||
markdown: string,
|
||||
options: SignalMarkdownOptions = {},
|
||||
): SignalFormattedText {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
enableSpoilers: true,
|
||||
headingStyle: "bold",
|
||||
blockquotePrefix: "> ",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
return renderSignalText(ir);
|
||||
}
|
||||
|
||||
function sliceSignalStyles(
|
||||
styles: SignalTextStyleRange[],
|
||||
start: number,
|
||||
end: number,
|
||||
): SignalTextStyleRange[] {
|
||||
const sliced: SignalTextStyleRange[] = [];
|
||||
for (const style of styles) {
|
||||
const styleEnd = style.start + style.length;
|
||||
const sliceStart = Math.max(style.start, start);
|
||||
const sliceEnd = Math.min(styleEnd, end);
|
||||
if (sliceEnd > sliceStart) {
|
||||
sliced.push({
|
||||
start: sliceStart - start,
|
||||
length: sliceEnd - sliceStart,
|
||||
style: style.style,
|
||||
});
|
||||
}
|
||||
}
|
||||
return sliced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split Signal formatted text into chunks under the limit while preserving styles.
|
||||
*
|
||||
* This implementation deterministically tracks cursor position without using indexOf,
|
||||
* which is fragile when chunks are trimmed or when duplicate substrings exist.
|
||||
* Styles spanning chunk boundaries are split into separate ranges for each chunk.
|
||||
*/
|
||||
function splitSignalFormattedText(
|
||||
formatted: SignalFormattedText,
|
||||
limit: number,
|
||||
): SignalFormattedText[] {
|
||||
const { text, styles } = formatted;
|
||||
|
||||
if (text.length <= limit) {
|
||||
return [formatted];
|
||||
}
|
||||
|
||||
const results: SignalFormattedText[] = [];
|
||||
let remaining = text;
|
||||
let offset = 0; // Track position in original text for style slicing
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= limit) {
|
||||
// Last chunk - take everything remaining
|
||||
const trimmed = remaining.trimEnd();
|
||||
if (trimmed.length > 0) {
|
||||
results.push({
|
||||
text: trimmed,
|
||||
styles: mergeStyles(sliceSignalStyles(styles, offset, offset + trimmed.length)),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Find a good break point within the limit
|
||||
const window = remaining.slice(0, limit);
|
||||
let breakIdx = findBreakIndex(window);
|
||||
|
||||
// If no good break point found, hard break at limit
|
||||
if (breakIdx <= 0) {
|
||||
breakIdx = limit;
|
||||
}
|
||||
|
||||
// Extract chunk and trim trailing whitespace
|
||||
const rawChunk = remaining.slice(0, breakIdx);
|
||||
const chunk = rawChunk.trimEnd();
|
||||
|
||||
if (chunk.length > 0) {
|
||||
results.push({
|
||||
text: chunk,
|
||||
styles: mergeStyles(sliceSignalStyles(styles, offset, offset + chunk.length)),
|
||||
});
|
||||
}
|
||||
|
||||
// Advance past the chunk and any whitespace separator
|
||||
const brokeOnWhitespace = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnWhitespace ? 1 : 0));
|
||||
|
||||
// Chunks are sent as separate messages, so we intentionally drop boundary whitespace.
|
||||
// Keep `offset` in sync with the dropped characters so style slicing stays correct.
|
||||
remaining = remaining.slice(nextStart).trimStart();
|
||||
offset = text.length - remaining.length;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best break index within a text window.
|
||||
* Prefers newlines over whitespace, avoids breaking inside parentheses.
|
||||
*/
|
||||
function findBreakIndex(window: string): number {
|
||||
let lastNewline = -1;
|
||||
let lastWhitespace = -1;
|
||||
let parenDepth = 0;
|
||||
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
const char = window[i];
|
||||
|
||||
if (char === "(") {
|
||||
parenDepth++;
|
||||
continue;
|
||||
}
|
||||
if (char === ")" && parenDepth > 0) {
|
||||
parenDepth--;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only consider break points outside parentheses
|
||||
if (parenDepth === 0) {
|
||||
if (char === "\n") {
|
||||
lastNewline = i;
|
||||
} else if (/\s/.test(char)) {
|
||||
lastWhitespace = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer newline break, fall back to whitespace
|
||||
return lastNewline > 0 ? lastNewline : lastWhitespace;
|
||||
}
|
||||
|
||||
export function markdownToSignalTextChunks(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
options: SignalMarkdownOptions = {},
|
||||
): SignalFormattedText[] {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: true,
|
||||
enableSpoilers: true,
|
||||
headingStyle: "bold",
|
||||
blockquotePrefix: "> ",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
const chunks = chunkMarkdownIR(ir, limit);
|
||||
const results: SignalFormattedText[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const rendered = renderSignalText(chunk);
|
||||
// If link expansion caused the chunk to exceed the limit, re-chunk it
|
||||
if (rendered.text.length > limit) {
|
||||
results.push(...splitSignalFormattedText(rendered, limit));
|
||||
} else {
|
||||
results.push(rendered);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/format
|
||||
export * from "../../extensions/signal/src/format.js";
|
||||
|
||||
@@ -1,57 +1,2 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalText } from "./format.js";
|
||||
|
||||
describe("markdownToSignalText", () => {
|
||||
describe("headings visual distinction", () => {
|
||||
it("renders headings as bold text", () => {
|
||||
const res = markdownToSignalText("# Heading 1");
|
||||
expect(res.text).toBe("Heading 1");
|
||||
expect(res.styles).toContainEqual({ start: 0, length: 9, style: "BOLD" });
|
||||
});
|
||||
|
||||
it("renders h2 headings as bold text", () => {
|
||||
const res = markdownToSignalText("## Heading 2");
|
||||
expect(res.text).toBe("Heading 2");
|
||||
expect(res.styles).toContainEqual({ start: 0, length: 9, style: "BOLD" });
|
||||
});
|
||||
|
||||
it("renders h3 headings as bold text", () => {
|
||||
const res = markdownToSignalText("### Heading 3");
|
||||
expect(res.text).toBe("Heading 3");
|
||||
expect(res.styles).toContainEqual({ start: 0, length: 9, style: "BOLD" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("blockquote visual distinction", () => {
|
||||
it("renders blockquotes with a visible prefix", () => {
|
||||
const res = markdownToSignalText("> This is a quote");
|
||||
// Should have some kind of prefix to distinguish it
|
||||
expect(res.text).toMatch(/^[│>]/);
|
||||
expect(res.text).toContain("This is a quote");
|
||||
});
|
||||
|
||||
it("renders multi-line blockquotes with prefix", () => {
|
||||
const res = markdownToSignalText("> Line 1\n> Line 2");
|
||||
// Should start with the prefix
|
||||
expect(res.text).toMatch(/^[│>]/);
|
||||
expect(res.text).toContain("Line 1");
|
||||
expect(res.text).toContain("Line 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("horizontal rule rendering", () => {
|
||||
it("renders horizontal rules as a visible separator", () => {
|
||||
const res = markdownToSignalText("Para 1\n\n---\n\nPara 2");
|
||||
// Should contain some kind of visual separator like ───
|
||||
expect(res.text).toMatch(/[─—-]{3,}/);
|
||||
});
|
||||
|
||||
it("renders horizontal rule between content", () => {
|
||||
const res = markdownToSignalText("Above\n\n***\n\nBelow");
|
||||
expect(res.text).toContain("Above");
|
||||
expect(res.text).toContain("Below");
|
||||
// Should have a separator
|
||||
expect(res.text).toMatch(/[─—-]{3,}/);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/format.visual.test
|
||||
export * from "../../extensions/signal/src/format.visual.test.js";
|
||||
|
||||
@@ -1,56 +1,2 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
looksLikeUuid,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
} from "./identity.js";
|
||||
|
||||
describe("looksLikeUuid", () => {
|
||||
it("accepts hyphenated UUIDs", () => {
|
||||
expect(looksLikeUuid("123e4567-e89b-12d3-a456-426614174000")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts compact UUIDs", () => {
|
||||
expect(looksLikeUuid("123e4567e89b12d3a456426614174000")).toBe(true); // pragma: allowlist secret
|
||||
});
|
||||
|
||||
it("accepts uuid-like hex values with letters", () => {
|
||||
expect(looksLikeUuid("abcd-1234")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects numeric ids and phone-like values", () => {
|
||||
expect(looksLikeUuid("1234567890")).toBe(false);
|
||||
expect(looksLikeUuid("+15555551212")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signal sender identity", () => {
|
||||
it("prefers sourceNumber over sourceUuid", () => {
|
||||
const sender = resolveSignalSender({
|
||||
sourceNumber: " +15550001111 ",
|
||||
sourceUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
expect(sender).toEqual({
|
||||
kind: "phone",
|
||||
raw: "+15550001111",
|
||||
e164: "+15550001111",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses sourceUuid when sourceNumber is missing", () => {
|
||||
const sender = resolveSignalSender({
|
||||
sourceUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
expect(sender).toEqual({
|
||||
kind: "uuid",
|
||||
raw: "123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps uuid senders to recipient and peer ids", () => {
|
||||
const sender = { kind: "uuid", raw: "123e4567-e89b-12d3-a456-426614174000" } as const;
|
||||
expect(resolveSignalRecipient(sender)).toBe("123e4567-e89b-12d3-a456-426614174000");
|
||||
expect(resolveSignalPeerId(sender)).toBe("uuid:123e4567-e89b-12d3-a456-426614174000");
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/identity.test
|
||||
export * from "../../extensions/signal/src/identity.test.js";
|
||||
|
||||
@@ -1,139 +1,2 @@
|
||||
import { evaluateSenderGroupAccessForPolicy } from "../plugin-sdk/group-access.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
|
||||
export type SignalSender =
|
||||
| { kind: "phone"; raw: string; e164: string }
|
||||
| { kind: "uuid"; raw: string };
|
||||
|
||||
type SignalAllowEntry =
|
||||
| { kind: "any" }
|
||||
| { kind: "phone"; e164: string }
|
||||
| { kind: "uuid"; raw: string };
|
||||
|
||||
const UUID_HYPHENATED_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_COMPACT_RE = /^[0-9a-f]{32}$/i;
|
||||
|
||||
export function looksLikeUuid(value: string): boolean {
|
||||
if (UUID_HYPHENATED_RE.test(value) || UUID_COMPACT_RE.test(value)) {
|
||||
return true;
|
||||
}
|
||||
const compact = value.replace(/-/g, "");
|
||||
if (!/^[0-9a-f]+$/i.test(compact)) {
|
||||
return false;
|
||||
}
|
||||
return /[a-f]/i.test(compact);
|
||||
}
|
||||
|
||||
function stripSignalPrefix(value: string): string {
|
||||
return value.replace(/^signal:/i, "").trim();
|
||||
}
|
||||
|
||||
export function resolveSignalSender(params: {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
}): SignalSender | null {
|
||||
const sourceNumber = params.sourceNumber?.trim();
|
||||
if (sourceNumber) {
|
||||
return {
|
||||
kind: "phone",
|
||||
raw: sourceNumber,
|
||||
e164: normalizeE164(sourceNumber),
|
||||
};
|
||||
}
|
||||
const sourceUuid = params.sourceUuid?.trim();
|
||||
if (sourceUuid) {
|
||||
return { kind: "uuid", raw: sourceUuid };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatSignalSenderId(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
export function formatSignalSenderDisplay(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
export function formatSignalPairingIdLine(sender: SignalSender): string {
|
||||
if (sender.kind === "phone") {
|
||||
return `Your Signal number: ${sender.e164}`;
|
||||
}
|
||||
return `Your Signal sender id: ${formatSignalSenderId(sender)}`;
|
||||
}
|
||||
|
||||
export function resolveSignalRecipient(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : sender.raw;
|
||||
}
|
||||
|
||||
export function resolveSignalPeerId(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
function parseSignalAllowEntry(entry: string): SignalAllowEntry | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return { kind: "any" };
|
||||
}
|
||||
|
||||
const stripped = stripSignalPrefix(trimmed);
|
||||
const lower = stripped.toLowerCase();
|
||||
if (lower.startsWith("uuid:")) {
|
||||
const raw = stripped.slice("uuid:".length).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "uuid", raw };
|
||||
}
|
||||
|
||||
if (looksLikeUuid(stripped)) {
|
||||
return { kind: "uuid", raw: stripped };
|
||||
}
|
||||
|
||||
return { kind: "phone", e164: normalizeE164(stripped) };
|
||||
}
|
||||
|
||||
export function normalizeSignalAllowRecipient(entry: string): string | undefined {
|
||||
const parsed = parseSignalAllowEntry(entry);
|
||||
if (!parsed || parsed.kind === "any") {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.kind === "phone" ? parsed.e164 : parsed.raw;
|
||||
}
|
||||
|
||||
export function isSignalSenderAllowed(sender: SignalSender, allowFrom: string[]): boolean {
|
||||
if (allowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const parsed = allowFrom
|
||||
.map(parseSignalAllowEntry)
|
||||
.filter((entry): entry is SignalAllowEntry => entry !== null);
|
||||
if (parsed.some((entry) => entry.kind === "any")) {
|
||||
return true;
|
||||
}
|
||||
return parsed.some((entry) => {
|
||||
if (entry.kind === "phone" && sender.kind === "phone") {
|
||||
return entry.e164 === sender.e164;
|
||||
}
|
||||
if (entry.kind === "uuid" && sender.kind === "uuid") {
|
||||
return entry.raw === sender.raw;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function isSignalGroupAllowed(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
allowFrom: string[];
|
||||
sender: SignalSender;
|
||||
}): boolean {
|
||||
return evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy: params.groupPolicy,
|
||||
groupAllowFrom: params.allowFrom,
|
||||
senderId: params.sender.raw,
|
||||
isSenderAllowed: () => isSignalSenderAllowed(params.sender, params.allowFrom),
|
||||
}).allowed;
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/identity
|
||||
export * from "../../extensions/signal/src/identity.js";
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
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";
|
||||
// Shim: re-exports from extensions/signal/src/index
|
||||
export * from "../../extensions/signal/src/index.js";
|
||||
|
||||
@@ -1,67 +1,2 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSignalGroupAllowed } from "./identity.js";
|
||||
|
||||
describe("signal groupPolicy gating", () => {
|
||||
it("allows when policy is open", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks when policy is disabled", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["+15550001111"],
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks allowlist when empty", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows allowlist when sender matches", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist wildcard", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["*"],
|
||||
sender: { kind: "phone", raw: "+15550002222", e164: "+15550002222" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist when uuid sender matches", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["uuid:123e4567-e89b-12d3-a456-426614174000"],
|
||||
sender: {
|
||||
kind: "uuid",
|
||||
raw: "123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/monitor.test
|
||||
export * from "../../extensions/signal/src/monitor.test.js";
|
||||
|
||||
@@ -1,119 +1,2 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
config,
|
||||
flush,
|
||||
getSignalToolResultTestMocks,
|
||||
installSignalToolResultTestHooks,
|
||||
setSignalToolResultTestConfig,
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
|
||||
installSignalToolResultTestHooks();
|
||||
|
||||
// Import after the harness registers `vi.mock(...)` for Signal internals.
|
||||
const { monitorSignalProvider } = await import("./monitor.js");
|
||||
|
||||
const { replyMock, sendMock, streamMock, upsertPairingRequestMock } =
|
||||
getSignalToolResultTestMocks();
|
||||
|
||||
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
|
||||
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider(opts);
|
||||
}
|
||||
describe("monitorSignalProvider tool results", () => {
|
||||
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
|
||||
const baseChannels = (config.channels ?? {}) as Record<string, unknown>;
|
||||
const baseSignal = (baseChannels.signal ?? {}) as Record<string, unknown>;
|
||||
setSignalToolResultTestConfig({
|
||||
...config,
|
||||
channels: {
|
||||
...baseChannels,
|
||||
signal: {
|
||||
...baseSignal,
|
||||
autoStart: false,
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const uuid = "123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
const payload = {
|
||||
envelope: {
|
||||
sourceUuid: uuid,
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
};
|
||||
await onEvent({
|
||||
event: "receive",
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "signal",
|
||||
id: `uuid:${uuid}`,
|
||||
meta: expect.objectContaining({ name: "Ada" }),
|
||||
}),
|
||||
);
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0]?.[0]).toBe(`signal:${uuid}`);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
|
||||
`Your Signal sender id: uuid:${uuid}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("reconnects after stream errors until aborted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const abortController = new AbortController();
|
||||
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
let calls = 0;
|
||||
|
||||
streamMock.mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("stream dropped");
|
||||
}
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
try {
|
||||
const monitorPromise = monitorSignalProvider({
|
||||
autoStart: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
reconnectPolicy: {
|
||||
initialMs: 1,
|
||||
maxMs: 1,
|
||||
factor: 1,
|
||||
jitter: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await monitorPromise;
|
||||
|
||||
expect(streamMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
randomSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test
|
||||
export * from "../../extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.js";
|
||||
|
||||
@@ -1,497 +1,2 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { peekSystemEvents } from "../infra/system-events.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import type { SignalDaemonExitEvent } from "./daemon.js";
|
||||
import {
|
||||
createMockSignalDaemonHandle,
|
||||
config,
|
||||
flush,
|
||||
getSignalToolResultTestMocks,
|
||||
installSignalToolResultTestHooks,
|
||||
setSignalToolResultTestConfig,
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
|
||||
installSignalToolResultTestHooks();
|
||||
|
||||
// Import after the harness registers `vi.mock(...)` for Signal internals.
|
||||
const { monitorSignalProvider } = await import("./monitor.js");
|
||||
|
||||
const {
|
||||
replyMock,
|
||||
sendMock,
|
||||
streamMock,
|
||||
updateLastRouteMock,
|
||||
upsertPairingRequestMock,
|
||||
waitForTransportReadyMock,
|
||||
spawnSignalDaemonMock,
|
||||
} = getSignalToolResultTestMocks();
|
||||
|
||||
const SIGNAL_BASE_URL = "http://127.0.0.1:8080";
|
||||
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
|
||||
|
||||
function createMonitorRuntime() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
}
|
||||
|
||||
function setSignalAutoStartConfig(overrides: Record<string, unknown> = {}) {
|
||||
setSignalToolResultTestConfig(createSignalConfig(overrides));
|
||||
}
|
||||
|
||||
function createSignalConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const base = config as OpenClawConfig;
|
||||
const channels = (base.channels ?? {}) as Record<string, unknown>;
|
||||
const signal = (channels.signal ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...base,
|
||||
channels: {
|
||||
...channels,
|
||||
signal: {
|
||||
...signal,
|
||||
autoStart: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAutoAbortController() {
|
||||
const abortController = new AbortController();
|
||||
streamMock.mockImplementation(async () => {
|
||||
abortController.abort();
|
||||
return;
|
||||
});
|
||||
return abortController;
|
||||
}
|
||||
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider(opts);
|
||||
}
|
||||
|
||||
async function receiveSignalPayloads(params: {
|
||||
payloads: unknown[];
|
||||
opts?: Partial<MonitorSignalProviderOptions>;
|
||||
}) {
|
||||
const abortController = new AbortController();
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
for (const payload of params.payloads) {
|
||||
await onEvent({
|
||||
event: "receive",
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: false,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
...params.opts,
|
||||
});
|
||||
|
||||
await flush();
|
||||
}
|
||||
|
||||
function getDirectSignalEventsFor(sender: string) {
|
||||
const route = resolveAgentRoute({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: "signal",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: normalizeE164(sender) },
|
||||
});
|
||||
return peekSystemEvents(route.sessionKey);
|
||||
}
|
||||
|
||||
function makeBaseEnvelope(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function receiveSingleEnvelope(
|
||||
envelope: Record<string, unknown>,
|
||||
opts?: Partial<MonitorSignalProviderOptions>,
|
||||
) {
|
||||
await receiveSignalPayloads({
|
||||
payloads: [{ envelope }],
|
||||
opts,
|
||||
});
|
||||
}
|
||||
|
||||
function expectNoReplyDeliveryOrRouteUpdate() {
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(updateLastRouteMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function setReactionNotificationConfig(mode: "all" | "own", extra: Record<string, unknown> = {}) {
|
||||
setSignalToolResultTestConfig(
|
||||
createSignalConfig({
|
||||
autoStart: false,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
reactionNotifications: mode,
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectWaitForTransportReadyTimeout(timeoutMs: number) {
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("monitorSignalProvider tool results", () => {
|
||||
it("uses bounded readiness checks when auto-starting the daemon", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig();
|
||||
const abortController = createAutoAbortController();
|
||||
await runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: "signal daemon",
|
||||
timeoutMs: 30_000,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
pollIntervalMs: 150,
|
||||
runtime,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses startupTimeoutMs override when provided", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig({ startupTimeoutMs: 60_000 });
|
||||
const abortController = createAutoAbortController();
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
startupTimeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expectWaitForTransportReadyTimeout(90_000);
|
||||
});
|
||||
|
||||
it("caps startupTimeoutMs at 2 minutes", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig({ startupTimeoutMs: 180_000 });
|
||||
const abortController = createAutoAbortController();
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expectWaitForTransportReadyTimeout(120_000);
|
||||
});
|
||||
|
||||
it("fails fast when auto-started signal daemon exits during startup", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig();
|
||||
spawnSignalDaemonMock.mockReturnValueOnce(
|
||||
createMockSignalDaemonHandle({
|
||||
exited: Promise.resolve({ source: "process", code: 1, signal: null }),
|
||||
isExited: () => true,
|
||||
}),
|
||||
);
|
||||
waitForTransportReadyMock.mockImplementationOnce(
|
||||
async (params: { abortSignal?: AbortSignal | null }) => {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (params.abortSignal?.aborted) {
|
||||
reject(params.abortSignal.reason);
|
||||
return;
|
||||
}
|
||||
params.abortSignal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(params.abortSignal?.reason ?? new Error("aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
runtime,
|
||||
}),
|
||||
).rejects.toThrow(/signal daemon exited/i);
|
||||
});
|
||||
|
||||
it("treats daemon exit after user abort as clean shutdown", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig();
|
||||
const abortController = new AbortController();
|
||||
let exited = false;
|
||||
let resolveExit!: (value: SignalDaemonExitEvent) => void;
|
||||
const exitedPromise = new Promise<SignalDaemonExitEvent>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const stop = vi.fn(() => {
|
||||
if (exited) {
|
||||
return;
|
||||
}
|
||||
exited = true;
|
||||
resolveExit({ source: "process", code: null, signal: "SIGTERM" });
|
||||
});
|
||||
spawnSignalDaemonMock.mockReturnValueOnce(
|
||||
createMockSignalDaemonHandle({
|
||||
stop,
|
||||
exited: exitedPromise,
|
||||
isExited: () => exited,
|
||||
}),
|
||||
);
|
||||
streamMock.mockImplementationOnce(async () => {
|
||||
abortController.abort(new Error("stop"));
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
runtime,
|
||||
abortSignal: abortController.signal,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips tool summaries with responsePrefix", async () => {
|
||||
replyMock.mockResolvedValue({ text: "final reply" });
|
||||
|
||||
await receiveSignalPayloads({
|
||||
payloads: [
|
||||
{
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
|
||||
setSignalToolResultTestConfig(
|
||||
createSignalConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),
|
||||
);
|
||||
await receiveSignalPayloads({
|
||||
payloads: [
|
||||
{
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Your Signal number: +15550001111");
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Pairing code: PAIRCODE");
|
||||
});
|
||||
|
||||
it("ignores reaction-only messages", async () => {
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
reactionMessage: {
|
||||
emoji: "👍",
|
||||
targetAuthor: "+15550002222",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expectNoReplyDeliveryOrRouteUpdate();
|
||||
});
|
||||
|
||||
it("ignores reaction-only dataMessage.reaction events (don’t treat as broken attachments)", async () => {
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
dataMessage: {
|
||||
reaction: {
|
||||
emoji: "👍",
|
||||
targetAuthor: "+15550002222",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
attachments: [{}],
|
||||
},
|
||||
});
|
||||
|
||||
expectNoReplyDeliveryOrRouteUpdate();
|
||||
});
|
||||
|
||||
it("enqueues system events for reaction notifications", async () => {
|
||||
setReactionNotificationConfig("all");
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
reactionMessage: {
|
||||
emoji: "✅",
|
||||
targetAuthor: "+15550002222",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const events = getDirectSignalEventsFor("+15550001111");
|
||||
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks reaction notifications from unauthorized senders when dmPolicy is allowlist",
|
||||
mode: "all" as const,
|
||||
extra: { dmPolicy: "allowlist", allowFrom: ["+15550007777"] } as Record<string, unknown>,
|
||||
targetAuthor: "+15550002222",
|
||||
shouldEnqueue: false,
|
||||
},
|
||||
{
|
||||
name: "blocks reaction notifications from unauthorized senders when dmPolicy is pairing",
|
||||
mode: "own" as const,
|
||||
extra: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
account: "+15550009999",
|
||||
} as Record<string, unknown>,
|
||||
targetAuthor: "+15550009999",
|
||||
shouldEnqueue: false,
|
||||
},
|
||||
{
|
||||
name: "allows reaction notifications for allowlisted senders when dmPolicy is allowlist",
|
||||
mode: "all" as const,
|
||||
extra: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } as Record<string, unknown>,
|
||||
targetAuthor: "+15550002222",
|
||||
shouldEnqueue: true,
|
||||
},
|
||||
])("$name", async ({ mode, extra, targetAuthor, shouldEnqueue }) => {
|
||||
setReactionNotificationConfig(mode, extra);
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
reactionMessage: {
|
||||
emoji: "✅",
|
||||
targetAuthor,
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const events = getDirectSignalEventsFor("+15550001111");
|
||||
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(shouldEnqueue);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies on own reactions when target includes uuid + phone", async () => {
|
||||
setReactionNotificationConfig("own", { account: "+15550002222" });
|
||||
await receiveSingleEnvelope({
|
||||
...makeBaseEnvelope(),
|
||||
reactionMessage: {
|
||||
emoji: "✅",
|
||||
targetAuthor: "+15550002222",
|
||||
targetAuthorUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const events = getDirectSignalEventsFor("+15550001111");
|
||||
expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
|
||||
});
|
||||
|
||||
it("processes messages when reaction metadata is present", async () => {
|
||||
replyMock.mockResolvedValue({ text: "pong" });
|
||||
|
||||
await receiveSignalPayloads({
|
||||
payloads: [
|
||||
{
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
reactionMessage: {
|
||||
emoji: "👍",
|
||||
targetAuthor: "+15550002222",
|
||||
targetSentTimestamp: 2,
|
||||
},
|
||||
dataMessage: {
|
||||
message: "ping",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateLastRouteMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resend pairing code when a request is already pending", async () => {
|
||||
setSignalToolResultTestConfig(
|
||||
createSignalConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),
|
||||
);
|
||||
upsertPairingRequestMock
|
||||
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
|
||||
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
|
||||
|
||||
const payload = {
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
};
|
||||
await receiveSignalPayloads({
|
||||
payloads: [
|
||||
payload,
|
||||
{
|
||||
...payload,
|
||||
envelope: { ...payload.envelope, timestamp: 2 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test
|
||||
export * from "../../extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.js";
|
||||
|
||||
@@ -1,146 +1,2 @@
|
||||
import { beforeEach, vi } from "vitest";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetSystemEventsForTest } from "../infra/system-events.js";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js";
|
||||
|
||||
type SignalToolResultTestMocks = {
|
||||
waitForTransportReadyMock: MockFn;
|
||||
sendMock: MockFn;
|
||||
replyMock: MockFn;
|
||||
updateLastRouteMock: MockFn;
|
||||
readAllowFromStoreMock: MockFn;
|
||||
upsertPairingRequestMock: MockFn;
|
||||
streamMock: MockFn;
|
||||
signalCheckMock: MockFn;
|
||||
signalRpcRequestMock: MockFn;
|
||||
spawnSignalDaemonMock: MockFn;
|
||||
};
|
||||
|
||||
const waitForTransportReadyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const sendMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const replyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const updateLastRouteMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const readAllowFromStoreMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const upsertPairingRequestMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const streamMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const signalCheckMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const signalRpcRequestMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
const spawnSignalDaemonMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
|
||||
|
||||
export function getSignalToolResultTestMocks(): SignalToolResultTestMocks {
|
||||
return {
|
||||
waitForTransportReadyMock,
|
||||
sendMock,
|
||||
replyMock,
|
||||
updateLastRouteMock,
|
||||
readAllowFromStoreMock,
|
||||
upsertPairingRequestMock,
|
||||
streamMock,
|
||||
signalCheckMock,
|
||||
signalRpcRequestMock,
|
||||
spawnSignalDaemonMock,
|
||||
};
|
||||
}
|
||||
|
||||
export let config: Record<string, unknown> = {};
|
||||
|
||||
export function setSignalToolResultTestConfig(next: Record<string, unknown>) {
|
||||
config = next;
|
||||
}
|
||||
|
||||
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
export function createMockSignalDaemonHandle(
|
||||
overrides: {
|
||||
stop?: MockFn;
|
||||
exited?: Promise<SignalDaemonExitEvent>;
|
||||
isExited?: () => boolean;
|
||||
} = {},
|
||||
): SignalDaemonHandle {
|
||||
const stop = overrides.stop ?? (vi.fn() as unknown as MockFn);
|
||||
const exited = overrides.exited ?? new Promise<SignalDaemonExitEvent>(() => {});
|
||||
const isExited = overrides.isExited ?? (() => false);
|
||||
return {
|
||||
stop: stop as unknown as () => void,
|
||||
exited,
|
||||
isExited,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
||||
sendTypingSignal: vi.fn().mockResolvedValue(true),
|
||||
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
streamSignalEvents: (...args: unknown[]) => streamMock(...args),
|
||||
signalCheck: (...args: unknown[]) => signalCheckMock(...args),
|
||||
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./daemon.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./daemon.js")>();
|
||||
return {
|
||||
...actual,
|
||||
spawnSignalDaemon: (...args: unknown[]) => spawnSignalDaemonMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/transport-ready.js", () => ({
|
||||
waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args),
|
||||
}));
|
||||
|
||||
export function installSignalToolResultTestHooks() {
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
channels: {
|
||||
signal: { autoStart: false, dmPolicy: "open", allowFrom: ["*"] },
|
||||
},
|
||||
};
|
||||
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
streamMock.mockReset();
|
||||
signalCheckMock.mockReset().mockResolvedValue({});
|
||||
signalRpcRequestMock.mockReset().mockResolvedValue({});
|
||||
spawnSignalDaemonMock.mockReset().mockReturnValue(createMockSignalDaemonHandle());
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
resetSystemEventsForTest();
|
||||
});
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/monitor.tool-result.test-harness
|
||||
export * from "../../extensions/signal/src/monitor.tool-result.test-harness.js";
|
||||
|
||||
@@ -1,477 +1,2 @@
|
||||
import { chunkTextWithMode, resolveChunkMode, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../config/runtime-group-policy.js";
|
||||
import type { SignalReactionNotificationMode } from "../config/types.js";
|
||||
import type { BackoffPolicy } from "../infra/backoff.js";
|
||||
import { waitForTransportReady } from "../infra/transport-ready.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
|
||||
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
|
||||
import { createSignalEventHandler } from "./monitor/event-handler.js";
|
||||
import type {
|
||||
SignalAttachment,
|
||||
SignalReactionMessage,
|
||||
SignalReactionTarget,
|
||||
} from "./monitor/event-handler.types.js";
|
||||
import { sendMessageSignal } from "./send.js";
|
||||
import { runSignalSseLoop } from "./sse-reconnect.js";
|
||||
|
||||
export type MonitorSignalOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
config?: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
autoStart?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
cliPath?: string;
|
||||
httpHost?: string;
|
||||
httpPort?: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
reconnectPolicy?: Partial<BackoffPolicy>;
|
||||
};
|
||||
|
||||
function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
return opts.runtime ?? createNonExitingRuntime();
|
||||
}
|
||||
|
||||
function mergeAbortSignals(
|
||||
a?: AbortSignal,
|
||||
b?: AbortSignal,
|
||||
): { signal?: AbortSignal; dispose: () => void } {
|
||||
if (!a && !b) {
|
||||
return { signal: undefined, dispose: () => {} };
|
||||
}
|
||||
if (!a) {
|
||||
return { signal: b, dispose: () => {} };
|
||||
}
|
||||
if (!b) {
|
||||
return { signal: a, dispose: () => {} };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const abortFrom = (source: AbortSignal) => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(source.reason);
|
||||
}
|
||||
};
|
||||
if (a.aborted) {
|
||||
abortFrom(a);
|
||||
return { signal: controller.signal, dispose: () => {} };
|
||||
}
|
||||
if (b.aborted) {
|
||||
abortFrom(b);
|
||||
return { signal: controller.signal, dispose: () => {} };
|
||||
}
|
||||
const onAbortA = () => abortFrom(a);
|
||||
const onAbortB = () => abortFrom(b);
|
||||
a.addEventListener("abort", onAbortA, { once: true });
|
||||
b.addEventListener("abort", onAbortB, { once: true });
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose: () => {
|
||||
a.removeEventListener("abort", onAbortA);
|
||||
b.removeEventListener("abort", onAbortB);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSignalDaemonLifecycle(params: { abortSignal?: AbortSignal }) {
|
||||
let daemonHandle: SignalDaemonHandle | null = null;
|
||||
let daemonStopRequested = false;
|
||||
let daemonExitError: Error | undefined;
|
||||
const daemonAbortController = new AbortController();
|
||||
const mergedAbort = mergeAbortSignals(params.abortSignal, daemonAbortController.signal);
|
||||
const stop = () => {
|
||||
daemonStopRequested = true;
|
||||
daemonHandle?.stop();
|
||||
};
|
||||
const attach = (handle: SignalDaemonHandle) => {
|
||||
daemonHandle = handle;
|
||||
void handle.exited.then((exit) => {
|
||||
if (daemonStopRequested || params.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
daemonExitError = new Error(formatSignalDaemonExit(exit));
|
||||
if (!daemonAbortController.signal.aborted) {
|
||||
daemonAbortController.abort(daemonExitError);
|
||||
}
|
||||
});
|
||||
};
|
||||
const getExitError = () => daemonExitError;
|
||||
return {
|
||||
attach,
|
||||
stop,
|
||||
getExitError,
|
||||
abortSignal: mergedAbort.signal,
|
||||
dispose: mergedAbort.dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||
return normalizeStringEntries(raw);
|
||||
}
|
||||
|
||||
function resolveSignalReactionTargets(reaction: SignalReactionMessage): SignalReactionTarget[] {
|
||||
const targets: SignalReactionTarget[] = [];
|
||||
const uuid = reaction.targetAuthorUuid?.trim();
|
||||
if (uuid) {
|
||||
targets.push({ kind: "uuid", id: uuid, display: `uuid:${uuid}` });
|
||||
}
|
||||
const author = reaction.targetAuthor?.trim();
|
||||
if (author) {
|
||||
const normalized = normalizeE164(author);
|
||||
targets.push({ kind: "phone", id: normalized, display: normalized });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function isSignalReactionMessage(
|
||||
reaction: SignalReactionMessage | null | undefined,
|
||||
): reaction is SignalReactionMessage {
|
||||
if (!reaction) {
|
||||
return false;
|
||||
}
|
||||
const emoji = reaction.emoji?.trim();
|
||||
const timestamp = reaction.targetSentTimestamp;
|
||||
const hasTarget = Boolean(reaction.targetAuthor?.trim() || reaction.targetAuthorUuid?.trim());
|
||||
return Boolean(emoji && typeof timestamp === "number" && timestamp > 0 && hasTarget);
|
||||
}
|
||||
|
||||
function shouldEmitSignalReactionNotification(params: {
|
||||
mode?: SignalReactionNotificationMode;
|
||||
account?: string | null;
|
||||
targets?: SignalReactionTarget[];
|
||||
sender?: ReturnType<typeof resolveSignalSender> | null;
|
||||
allowlist?: string[];
|
||||
}) {
|
||||
const { mode, account, targets, sender, allowlist } = params;
|
||||
const effectiveMode = mode ?? "own";
|
||||
if (effectiveMode === "off") {
|
||||
return false;
|
||||
}
|
||||
if (effectiveMode === "own") {
|
||||
const accountId = account?.trim();
|
||||
if (!accountId || !targets || targets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalizedAccount = normalizeE164(accountId);
|
||||
return targets.some((target) => {
|
||||
if (target.kind === "uuid") {
|
||||
return accountId === target.id || accountId === `uuid:${target.id}`;
|
||||
}
|
||||
return normalizedAccount === target.id;
|
||||
});
|
||||
}
|
||||
if (effectiveMode === "allowlist") {
|
||||
if (!sender || !allowlist || allowlist.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return isSignalSenderAllowed(sender, allowlist);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildSignalReactionSystemEventText(params: {
|
||||
emojiLabel: string;
|
||||
actorLabel: string;
|
||||
messageId: string;
|
||||
targetLabel?: string;
|
||||
groupLabel?: string;
|
||||
}) {
|
||||
const base = `Signal reaction added: ${params.emojiLabel} by ${params.actorLabel} msg ${params.messageId}`;
|
||||
const withTarget = params.targetLabel ? `${base} from ${params.targetLabel}` : base;
|
||||
return params.groupLabel ? `${withTarget} in ${params.groupLabel}` : withTarget;
|
||||
}
|
||||
|
||||
async function waitForSignalDaemonReady(params: {
|
||||
baseUrl: string;
|
||||
abortSignal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
logAfterMs: number;
|
||||
logIntervalMs?: number;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<void> {
|
||||
await waitForTransportReady({
|
||||
label: "signal daemon",
|
||||
timeoutMs: params.timeoutMs,
|
||||
logAfterMs: params.logAfterMs,
|
||||
logIntervalMs: params.logIntervalMs,
|
||||
pollIntervalMs: 150,
|
||||
abortSignal: params.abortSignal,
|
||||
runtime: params.runtime,
|
||||
check: async () => {
|
||||
const res = await signalCheck(params.baseUrl, 1000);
|
||||
if (res.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAttachment(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
attachment: SignalAttachment;
|
||||
sender?: string;
|
||||
groupId?: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{ path: string; contentType?: string } | null> {
|
||||
const { attachment } = params;
|
||||
if (!attachment?.id) {
|
||||
return null;
|
||||
}
|
||||
if (attachment.size && attachment.size > params.maxBytes) {
|
||||
throw new Error(
|
||||
`Signal attachment ${attachment.id} exceeds ${(params.maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
|
||||
);
|
||||
}
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
id: attachment.id,
|
||||
};
|
||||
if (params.account) {
|
||||
rpcParams.account = params.account;
|
||||
}
|
||||
if (params.groupId) {
|
||||
rpcParams.groupId = params.groupId;
|
||||
} else if (params.sender) {
|
||||
rpcParams.recipient = params.sender;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await signalRpcRequest<{ data?: string }>("getAttachment", rpcParams, {
|
||||
baseUrl: params.baseUrl,
|
||||
});
|
||||
if (!result?.data) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(result.data, "base64");
|
||||
const saved = await saveMediaBuffer(
|
||||
buffer,
|
||||
attachment.contentType ?? undefined,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
chunkMode: "length" | "newline";
|
||||
}) {
|
||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
||||
params;
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (!text && mediaList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||
await sendMessageSignal(target, chunk, {
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await sendMessageSignal(target, caption, {
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl: url,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
accountInfo.config.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
||||
const chunkMode = resolveChunkMode(cfg, "signal", accountInfo.accountId);
|
||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? accountInfo.config.allowFrom);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
accountInfo.config.groupAllowFrom ??
|
||||
(accountInfo.config.allowFrom && accountInfo.config.allowFrom.length > 0
|
||||
? accountInfo.config.allowFrom
|
||||
: []),
|
||||
);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.signal !== undefined,
|
||||
groupPolicy: accountInfo.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
log: (message) => runtime.log?.(message),
|
||||
});
|
||||
const reactionMode = accountInfo.config.reactionNotifications ?? "own";
|
||||
const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist);
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||
|
||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||
const startupTimeoutMs = Math.min(
|
||||
120_000,
|
||||
Math.max(1_000, opts.startupTimeoutMs ?? accountInfo.config.startupTimeoutMs ?? 30_000),
|
||||
);
|
||||
const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts);
|
||||
const daemonLifecycle = createSignalDaemonLifecycle({ abortSignal: opts.abortSignal });
|
||||
let daemonHandle: SignalDaemonHandle | null = null;
|
||||
|
||||
if (autoStart) {
|
||||
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
|
||||
const httpHost = opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
|
||||
daemonHandle = spawnSignalDaemon({
|
||||
cliPath,
|
||||
account,
|
||||
httpHost,
|
||||
httpPort,
|
||||
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
|
||||
ignoreAttachments: opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
|
||||
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
|
||||
sendReadReceipts,
|
||||
runtime,
|
||||
});
|
||||
daemonLifecycle.attach(daemonHandle);
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
daemonLifecycle.stop();
|
||||
};
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
if (daemonHandle) {
|
||||
await waitForSignalDaemonReady({
|
||||
baseUrl,
|
||||
abortSignal: daemonLifecycle.abortSignal,
|
||||
timeoutMs: startupTimeoutMs,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
runtime,
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
}
|
||||
|
||||
const handleEvent = createSignalEventHandler({
|
||||
runtime,
|
||||
cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
accountUuid: accountInfo.config.accountUuid,
|
||||
accountId: accountInfo.accountId,
|
||||
blockStreaming: accountInfo.config.blockStreaming,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
textLimit,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
groupPolicy,
|
||||
reactionMode,
|
||||
reactionAllowlist,
|
||||
mediaMaxBytes,
|
||||
ignoreAttachments,
|
||||
sendReadReceipts,
|
||||
readReceiptsViaDaemon,
|
||||
fetchAttachment,
|
||||
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }),
|
||||
resolveSignalReactionTargets,
|
||||
isSignalReactionMessage,
|
||||
shouldEmitSignalReactionNotification,
|
||||
buildSignalReactionSystemEventText,
|
||||
});
|
||||
|
||||
await runSignalSseLoop({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal: daemonLifecycle.abortSignal,
|
||||
runtime,
|
||||
policy: opts.reconnectPolicy,
|
||||
onEvent: (event) => {
|
||||
void handleEvent(event).catch((err) => {
|
||||
runtime.error?.(`event handler failed: ${String(err)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
} catch (err) {
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (opts.abortSignal?.aborted && !daemonExitError) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
daemonLifecycle.dispose();
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
daemonLifecycle.stop();
|
||||
}
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/monitor
|
||||
export * from "../../extensions/signal/src/monitor.js";
|
||||
|
||||
@@ -1,87 +1,2 @@
|
||||
import { issuePairingChallenge } from "../../pairing/pairing-challenge.js";
|
||||
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "../../security/dm-policy-shared.js";
|
||||
import { isSignalSenderAllowed, type SignalSender } from "../identity.js";
|
||||
|
||||
type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type SignalGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
export async function resolveSignalAccessState(params: {
|
||||
accountId: string;
|
||||
dmPolicy: SignalDmPolicy;
|
||||
groupPolicy: SignalGroupPolicy;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
sender: SignalSender;
|
||||
}) {
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "signal",
|
||||
accountId: params.accountId,
|
||||
dmPolicy: params.dmPolicy,
|
||||
});
|
||||
const resolveAccessDecision = (isGroup: boolean) =>
|
||||
resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: params.groupPolicy,
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowEntries) => isSignalSenderAllowed(params.sender, allowEntries),
|
||||
});
|
||||
const dmAccess = resolveAccessDecision(false);
|
||||
return {
|
||||
resolveAccessDecision,
|
||||
dmAccess,
|
||||
effectiveDmAllow: dmAccess.effectiveAllowFrom,
|
||||
effectiveGroupAllow: dmAccess.effectiveGroupAllowFrom,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSignalDirectMessageAccess(params: {
|
||||
dmPolicy: SignalDmPolicy;
|
||||
dmAccessDecision: "allow" | "block" | "pairing";
|
||||
senderId: string;
|
||||
senderIdLine: string;
|
||||
senderDisplay: string;
|
||||
senderName?: string;
|
||||
accountId: string;
|
||||
sendPairingReply: (text: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (params.dmAccessDecision === "allow") {
|
||||
return true;
|
||||
}
|
||||
if (params.dmAccessDecision === "block") {
|
||||
if (params.dmPolicy !== "disabled") {
|
||||
params.log(`Blocked signal sender ${params.senderDisplay} (dmPolicy=${params.dmPolicy})`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (params.dmPolicy === "pairing") {
|
||||
await issuePairingChallenge({
|
||||
channel: "signal",
|
||||
senderId: params.senderId,
|
||||
senderIdLine: params.senderIdLine,
|
||||
meta: { name: params.senderName },
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "signal",
|
||||
id,
|
||||
accountId: params.accountId,
|
||||
meta,
|
||||
}),
|
||||
sendPairingReply: params.sendPairingReply,
|
||||
onCreated: () => {
|
||||
params.log(`signal pairing request sender=${params.senderId}`);
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
params.log(`signal pairing reply failed for ${params.senderId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/monitor/access-policy
|
||||
export * from "../../../extensions/signal/src/monitor/access-policy.js";
|
||||
|
||||
@@ -1,262 +1,2 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { createSignalEventHandler } from "./event-handler.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
createSignalReceiveEvent,
|
||||
} from "./event-handler.test-harness.js";
|
||||
|
||||
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
|
||||
() => {
|
||||
const captureState: { ctx: MsgContext | undefined } = { ctx: undefined };
|
||||
return {
|
||||
sendTypingMock: vi.fn(),
|
||||
sendReadReceiptMock: vi.fn(),
|
||||
dispatchInboundMessageMock: vi.fn(
|
||||
async (params: {
|
||||
ctx: MsgContext;
|
||||
replyOptions?: { onReplyStart?: () => void | Promise<void> };
|
||||
}) => {
|
||||
captureState.ctx = params.ctx;
|
||||
await Promise.resolve(params.replyOptions?.onReplyStart?.());
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
||||
},
|
||||
),
|
||||
capture: captureState,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendTypingSignal: sendTypingMock,
|
||||
sendReadReceiptSignal: sendReadReceiptMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../auto-reply/dispatch.js")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchInboundMessage: dispatchInboundMessageMock,
|
||||
dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock,
|
||||
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||
upsertChannelPairingRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("signal createSignalEventHandler inbound contract", () => {
|
||||
beforeEach(() => {
|
||||
capture.ctx = undefined;
|
||||
sendTypingMock.mockReset().mockResolvedValue(true);
|
||||
sendReadReceiptMock.mockReset().mockResolvedValue(true);
|
||||
dispatchInboundMessageMock.mockClear();
|
||||
});
|
||||
|
||||
it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "hi",
|
||||
attachments: [],
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expectInboundContextContract(capture.ctx!);
|
||||
const contextWithBody = capture.ctx!;
|
||||
// Sender should appear as prefix in group messages (no redundant [from:] suffix)
|
||||
expect(String(contextWithBody.Body ?? "")).toContain("Alice");
|
||||
expect(String(contextWithBody.Body ?? "")).toMatch(/Alice.*:/);
|
||||
expect(String(contextWithBody.Body ?? "")).not.toContain("[from:");
|
||||
});
|
||||
|
||||
it("normalizes direct chat To/OriginatingTo targets to canonical Signal ids", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
sourceNumber: "+15550002222",
|
||||
sourceName: "Bob",
|
||||
timestamp: 1700000000001,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
const context = capture.ctx!;
|
||||
expect(context.ChatType).toBe("direct");
|
||||
expect(context.To).toBe("+15550002222");
|
||||
expect(context.OriginatingTo).toBe("+15550002222");
|
||||
});
|
||||
|
||||
it("sends typing + read receipt for allowed DMs", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
},
|
||||
account: "+15550009999",
|
||||
blockStreaming: false,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
sendReadReceipts: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "hi",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendTypingMock).toHaveBeenCalledWith("+15550001111", expect.any(Object));
|
||||
expect(sendReadReceiptMock).toHaveBeenCalledWith(
|
||||
"signal:+15550001111",
|
||||
1700000000000,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: [] } },
|
||||
},
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
account: "+15550009999",
|
||||
blockStreaming: false,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "/status",
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expect(capture.ctx?.CommandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards all fetched attachments via MediaPaths/MediaTypes", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
},
|
||||
ignoreAttachments: false,
|
||||
fetchAttachment: async ({ attachment }) => ({
|
||||
path: `/tmp/${String(attachment.id)}.dat`,
|
||||
contentType: attachment.id === "a1" ? "image/jpeg" : undefined,
|
||||
}),
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "",
|
||||
attachments: [{ id: "a1", contentType: "image/jpeg" }, { id: "a2" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expect(capture.ctx?.MediaPath).toBe("/tmp/a1.dat");
|
||||
expect(capture.ctx?.MediaType).toBe("image/jpeg");
|
||||
expect(capture.ctx?.MediaPaths).toEqual(["/tmp/a1.dat", "/tmp/a2.dat"]);
|
||||
expect(capture.ctx?.MediaUrls).toEqual(["/tmp/a1.dat", "/tmp/a2.dat"]);
|
||||
expect(capture.ctx?.MediaTypes).toEqual(["image/jpeg", "application/octet-stream"]);
|
||||
});
|
||||
|
||||
it("drops own UUID inbound messages when only accountUuid is configured", async () => {
|
||||
const ownUuid = "123e4567-e89b-12d3-a456-426614174000";
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"], accountUuid: ownUuid } },
|
||||
},
|
||||
account: undefined,
|
||||
accountUuid: ownUuid,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
sourceNumber: null,
|
||||
sourceUuid: ownUuid,
|
||||
dataMessage: {
|
||||
message: "self message",
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeUndefined();
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops sync envelopes when syncMessage is present but null", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
},
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
syncMessage: null,
|
||||
dataMessage: {
|
||||
message: "replayed sentTranscript envelope",
|
||||
attachments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeUndefined();
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/monitor/event-handler.inbound-contract.test
|
||||
export * from "../../../extensions/signal/src/monitor/event-handler.inbound-contract.test.js";
|
||||
|
||||
@@ -1,299 +1,2 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildDispatchInboundCaptureMock } from "../../../test/helpers/dispatch-inbound-capture.js";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../../config/types.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
createSignalReceiveEvent,
|
||||
} from "./event-handler.test-harness.js";
|
||||
|
||||
type SignalMsgContext = Pick<MsgContext, "Body" | "WasMentioned"> & {
|
||||
Body?: string;
|
||||
WasMentioned?: boolean;
|
||||
};
|
||||
|
||||
let capturedCtx: SignalMsgContext | undefined;
|
||||
|
||||
function getCapturedCtx() {
|
||||
return capturedCtx as SignalMsgContext;
|
||||
}
|
||||
|
||||
vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../auto-reply/dispatch.js")>();
|
||||
return buildDispatchInboundCaptureMock(actual, (ctx) => {
|
||||
capturedCtx = ctx as SignalMsgContext;
|
||||
});
|
||||
});
|
||||
|
||||
import { createSignalEventHandler } from "./event-handler.js";
|
||||
import { renderSignalMentions } from "./mentions.js";
|
||||
|
||||
type GroupEventOpts = {
|
||||
message?: string;
|
||||
attachments?: unknown[];
|
||||
quoteText?: string;
|
||||
mentions?: Array<{
|
||||
uuid?: string;
|
||||
number?: string;
|
||||
start?: number;
|
||||
length?: number;
|
||||
}> | null;
|
||||
};
|
||||
|
||||
function makeGroupEvent(opts: GroupEventOpts) {
|
||||
return createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: opts.message ?? "",
|
||||
attachments: opts.attachments ?? [],
|
||||
quote: opts.quoteText ? { text: opts.quoteText } : undefined,
|
||||
mentions: opts.mentions ?? undefined,
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMentionHandler(params: {
|
||||
requireMention: boolean;
|
||||
mentionPattern?: string;
|
||||
historyLimit?: number;
|
||||
groupHistories?: ReturnType<typeof createBaseSignalEventHandlerDeps>["groupHistories"];
|
||||
}) {
|
||||
return createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: createSignalConfig({
|
||||
requireMention: params.requireMention,
|
||||
mentionPattern: params.mentionPattern,
|
||||
}),
|
||||
...(typeof params.historyLimit === "number" ? { historyLimit: params.historyLimit } : {}),
|
||||
...(params.groupHistories ? { groupHistories: params.groupHistories } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createMentionGatedHistoryHandler() {
|
||||
const groupHistories = new Map();
|
||||
const handler = createMentionHandler({ requireMention: true, historyLimit: 5, groupHistories });
|
||||
return { handler, groupHistories };
|
||||
}
|
||||
|
||||
function createSignalConfig(params: { requireMention: boolean; mentionPattern?: string }) {
|
||||
return {
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
groupChat: { mentionPatterns: [params.mentionPattern ?? "@bot"] },
|
||||
},
|
||||
channels: {
|
||||
signal: {
|
||||
groups: { "*": { requireMention: params.requireMention } },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function expectSkippedGroupHistory(opts: GroupEventOpts, expectedBody: string) {
|
||||
capturedCtx = undefined;
|
||||
const { handler, groupHistories } = createMentionGatedHistoryHandler();
|
||||
await handler(makeGroupEvent(opts));
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
const entries = groupHistories.get("g1");
|
||||
expect(entries).toBeTruthy();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].body).toBe(expectedBody);
|
||||
}
|
||||
|
||||
describe("signal mention gating", () => {
|
||||
it("drops group messages without mention when requireMention is configured", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hello everyone" }));
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows group messages with mention when requireMention is configured", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hey @bot what's up" }));
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
expect(getCapturedCtx()?.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("sets WasMentioned=false for group messages without mention when requireMention is off", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: false });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hello everyone" }));
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
expect(getCapturedCtx()?.WasMentioned).toBe(false);
|
||||
});
|
||||
|
||||
it("records pending history for skipped group messages", async () => {
|
||||
capturedCtx = undefined;
|
||||
const { handler, groupHistories } = createMentionGatedHistoryHandler();
|
||||
await handler(makeGroupEvent({ message: "hello from alice" }));
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
const entries = groupHistories.get("g1");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].sender).toBe("Alice");
|
||||
expect(entries[0].body).toBe("hello from alice");
|
||||
});
|
||||
|
||||
it("records attachment placeholder in pending history for skipped attachment-only group messages", async () => {
|
||||
await expectSkippedGroupHistory(
|
||||
{ message: "", attachments: [{ id: "a1" }] },
|
||||
"<media:attachment>",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes mixed-case parameterized attachment MIME in skipped pending history", async () => {
|
||||
capturedCtx = undefined;
|
||||
const groupHistories = new Map();
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: createSignalConfig({ requireMention: true }),
|
||||
historyLimit: 5,
|
||||
groupHistories,
|
||||
ignoreAttachments: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
makeGroupEvent({
|
||||
message: "",
|
||||
attachments: [{ contentType: " Audio/Ogg; codecs=opus " }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
const entries = groupHistories.get("g1");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].body).toBe("<media:audio>");
|
||||
});
|
||||
|
||||
it("summarizes multiple skipped attachments with stable file count wording", async () => {
|
||||
capturedCtx = undefined;
|
||||
const groupHistories = new Map();
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: createSignalConfig({ requireMention: true }),
|
||||
historyLimit: 5,
|
||||
groupHistories,
|
||||
ignoreAttachments: false,
|
||||
fetchAttachment: async ({ attachment }) => ({
|
||||
path: `/tmp/${String(attachment.id)}.bin`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
makeGroupEvent({
|
||||
message: "",
|
||||
attachments: [{ id: "a1" }, { id: "a2" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
const entries = groupHistories.get("g1");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].body).toBe("[2 files attached]");
|
||||
});
|
||||
|
||||
it("records quote text in pending history for skipped quote-only group messages", async () => {
|
||||
await expectSkippedGroupHistory({ message: "", quoteText: "quoted context" }, "quoted context");
|
||||
});
|
||||
|
||||
it("bypasses mention gating for authorized control commands", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "/help" }));
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hydrates mention placeholders before trimming so offsets stay aligned", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: false });
|
||||
|
||||
const placeholder = "\uFFFC";
|
||||
const message = `\n${placeholder} hi ${placeholder}`;
|
||||
const firstStart = message.indexOf(placeholder);
|
||||
const secondStart = message.indexOf(placeholder, firstStart + 1);
|
||||
|
||||
await handler(
|
||||
makeGroupEvent({
|
||||
message,
|
||||
mentions: [
|
||||
{ uuid: "123e4567", start: firstStart, length: placeholder.length },
|
||||
{ number: "+15550002222", start: secondStart, length: placeholder.length },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
const body = String(getCapturedCtx()?.Body ?? "");
|
||||
expect(body).toContain("@123e4567 hi @+15550002222");
|
||||
expect(body).not.toContain(placeholder);
|
||||
});
|
||||
|
||||
it("counts mention metadata replacements toward requireMention gating", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({
|
||||
requireMention: true,
|
||||
mentionPattern: "@123e4567",
|
||||
});
|
||||
|
||||
const placeholder = "\uFFFC";
|
||||
const message = ` ${placeholder} ping`;
|
||||
const start = message.indexOf(placeholder);
|
||||
|
||||
await handler(
|
||||
makeGroupEvent({
|
||||
message,
|
||||
mentions: [{ uuid: "123e4567", start, length: placeholder.length }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedCtx).toBeTruthy();
|
||||
expect(String(getCapturedCtx()?.Body ?? "")).toContain("@123e4567");
|
||||
expect(getCapturedCtx()?.WasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSignalMentions", () => {
|
||||
const PLACEHOLDER = "\uFFFC";
|
||||
|
||||
it("returns the original message when no mentions are provided", () => {
|
||||
const message = `${PLACEHOLDER} ping`;
|
||||
expect(renderSignalMentions(message, null)).toBe(message);
|
||||
expect(renderSignalMentions(message, [])).toBe(message);
|
||||
});
|
||||
|
||||
it("replaces placeholder code points using mention metadata", () => {
|
||||
const message = `${PLACEHOLDER} hi ${PLACEHOLDER}!`;
|
||||
const normalized = renderSignalMentions(message, [
|
||||
{ uuid: "abc-123", start: 0, length: 1 },
|
||||
{ number: "+15550005555", start: message.lastIndexOf(PLACEHOLDER), length: 1 },
|
||||
]);
|
||||
|
||||
expect(normalized).toBe("@abc-123 hi @+15550005555!");
|
||||
});
|
||||
|
||||
it("skips mentions that lack identifiers or out-of-bounds spans", () => {
|
||||
const message = `${PLACEHOLDER} hi`;
|
||||
const normalized = renderSignalMentions(message, [
|
||||
{ name: "ignored" },
|
||||
{ uuid: "valid", start: 0, length: 1 },
|
||||
{ number: "+1555", start: 999, length: 1 },
|
||||
]);
|
||||
|
||||
expect(normalized).toBe("@valid hi");
|
||||
});
|
||||
|
||||
it("clamps and truncates fractional mention offsets", () => {
|
||||
const message = `${PLACEHOLDER} ping`;
|
||||
const normalized = renderSignalMentions(message, [{ uuid: "valid", start: -0.7, length: 1.9 }]);
|
||||
|
||||
expect(normalized).toBe("@valid ping");
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/monitor/event-handler.mention-gating.test
|
||||
export * from "../../../extensions/signal/src/monitor/event-handler.mention-gating.test.js";
|
||||
|
||||
@@ -1,49 +1,2 @@
|
||||
import type { SignalEventHandlerDeps, SignalReactionMessage } from "./event-handler.types.js";
|
||||
|
||||
export function createBaseSignalEventHandlerDeps(
|
||||
overrides: Partial<SignalEventHandlerDeps> = {},
|
||||
): SignalEventHandlerDeps {
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
runtime: { log: () => {}, error: () => {} } as any,
|
||||
cfg: {},
|
||||
baseUrl: "http://localhost",
|
||||
accountId: "default",
|
||||
historyLimit: 5,
|
||||
groupHistories: new Map(),
|
||||
textLimit: 4000,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupAllowFrom: ["*"],
|
||||
groupPolicy: "open",
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
mediaMaxBytes: 1024,
|
||||
ignoreAttachments: true,
|
||||
sendReadReceipts: false,
|
||||
readReceiptsViaDaemon: false,
|
||||
fetchAttachment: async () => null,
|
||||
deliverReplies: async () => {},
|
||||
resolveSignalReactionTargets: () => [],
|
||||
isSignalReactionMessage: (
|
||||
_reaction: SignalReactionMessage | null | undefined,
|
||||
): _reaction is SignalReactionMessage => false,
|
||||
shouldEmitSignalReactionNotification: () => false,
|
||||
buildSignalReactionSystemEventText: () => "reaction",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSignalReceiveEvent(envelopeOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
event: "receive",
|
||||
data: JSON.stringify({
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Alice",
|
||||
timestamp: 1700000000000,
|
||||
...envelopeOverrides,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/monitor/event-handler.test-harness
|
||||
export * from "../../../extensions/signal/src/monitor/event-handler.test-harness.js";
|
||||
|
||||
@@ -1,801 +1,2 @@
|
||||
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||
import {
|
||||
formatInboundEnvelope,
|
||||
formatInboundFromLabel,
|
||||
resolveEnvelopeFormatOptions,
|
||||
} from "../../auto-reply/envelope.js";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
} from "../../auto-reply/reply/history.js";
|
||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
||||
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
||||
import {
|
||||
createChannelInboundDebouncer,
|
||||
shouldDebounceTextInbound,
|
||||
} from "../../channels/inbound-debounce-policy.js";
|
||||
import { logInboundDrop, logTypingFailure } from "../../channels/logging.js";
|
||||
import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js";
|
||||
import { normalizeSignalMessagingTarget } from "../../channels/plugins/normalize/signal.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { recordInboundSession } from "../../channels/session.js";
|
||||
import { createTypingCallbacks } from "../../channels/typing.js";
|
||||
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { kindFromMime } from "../../media/mime.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
} from "../../security/dm-policy-shared.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import {
|
||||
formatSignalPairingIdLine,
|
||||
formatSignalSenderDisplay,
|
||||
formatSignalSenderId,
|
||||
isSignalSenderAllowed,
|
||||
normalizeSignalAllowRecipient,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
type SignalSender,
|
||||
} from "../identity.js";
|
||||
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
|
||||
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
|
||||
import type {
|
||||
SignalEnvelope,
|
||||
SignalEventHandlerDeps,
|
||||
SignalReactionMessage,
|
||||
SignalReceivePayload,
|
||||
} from "./event-handler.types.js";
|
||||
import { renderSignalMentions } from "./mentions.js";
|
||||
|
||||
function formatAttachmentKindCount(kind: string, count: number): string {
|
||||
if (kind === "attachment") {
|
||||
return `${count} file${count > 1 ? "s" : ""}`;
|
||||
}
|
||||
return `${count} ${kind}${count > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
function formatAttachmentSummaryPlaceholder(contentTypes: Array<string | undefined>): string {
|
||||
const kindCounts = new Map<string, number>();
|
||||
for (const contentType of contentTypes) {
|
||||
const kind = kindFromMime(contentType) ?? "attachment";
|
||||
kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
const parts = [...kindCounts.entries()].map(([kind, count]) =>
|
||||
formatAttachmentKindCount(kind, count),
|
||||
);
|
||||
return `[${parts.join(" + ")} attached]`;
|
||||
}
|
||||
|
||||
function resolveSignalInboundRoute(params: {
|
||||
cfg: SignalEventHandlerDeps["cfg"];
|
||||
accountId: SignalEventHandlerDeps["accountId"];
|
||||
isGroup: boolean;
|
||||
groupId?: string;
|
||||
senderPeerId: string;
|
||||
}) {
|
||||
return resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "signal",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: params.isGroup ? "group" : "direct",
|
||||
id: params.isGroup ? (params.groupId ?? "unknown") : params.senderPeerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
type SignalInboundEntry = {
|
||||
senderName: string;
|
||||
senderDisplay: string;
|
||||
senderRecipient: string;
|
||||
senderPeerId: string;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
isGroup: boolean;
|
||||
bodyText: string;
|
||||
commandBody: string;
|
||||
timestamp?: number;
|
||||
messageId?: string;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
mediaPaths?: string[];
|
||||
mediaTypes?: string[];
|
||||
commandAuthorized: boolean;
|
||||
wasMentioned?: boolean;
|
||||
};
|
||||
|
||||
async function handleSignalInboundMessage(entry: SignalInboundEntry) {
|
||||
const fromLabel = formatInboundFromLabel({
|
||||
isGroup: entry.isGroup,
|
||||
groupLabel: entry.groupName ?? undefined,
|
||||
groupId: entry.groupId ?? "unknown",
|
||||
groupFallback: "Group",
|
||||
directLabel: entry.senderName,
|
||||
directId: entry.senderDisplay,
|
||||
});
|
||||
const route = resolveSignalInboundRoute({
|
||||
cfg: deps.cfg,
|
||||
accountId: deps.accountId,
|
||||
isGroup: entry.isGroup,
|
||||
groupId: entry.groupId,
|
||||
senderPeerId: entry.senderPeerId,
|
||||
});
|
||||
const storePath = resolveStorePath(deps.cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg);
|
||||
const previousTimestamp = readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Signal",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp ?? undefined,
|
||||
body: entry.bodyText,
|
||||
chatType: entry.isGroup ? "group" : "direct",
|
||||
sender: { name: entry.senderName, id: entry.senderDisplay },
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
let combinedBody = body;
|
||||
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
|
||||
if (entry.isGroup && historyKey) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey,
|
||||
limit: deps.historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (historyEntry) =>
|
||||
formatInboundEnvelope({
|
||||
channel: "Signal",
|
||||
from: fromLabel,
|
||||
timestamp: historyEntry.timestamp,
|
||||
body: `${historyEntry.body}${
|
||||
historyEntry.messageId ? ` [id:${historyEntry.messageId}]` : ""
|
||||
}`,
|
||||
chatType: "group",
|
||||
senderLabel: historyEntry.sender,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const signalToRaw = entry.isGroup
|
||||
? `group:${entry.groupId}`
|
||||
: `signal:${entry.senderRecipient}`;
|
||||
const signalTo = normalizeSignalMessagingTarget(signalToRaw) ?? signalToRaw;
|
||||
const inboundHistory =
|
||||
entry.isGroup && historyKey && deps.historyLimit > 0
|
||||
? (deps.groupHistories.get(historyKey) ?? []).map((historyEntry) => ({
|
||||
sender: historyEntry.sender,
|
||||
body: historyEntry.body,
|
||||
timestamp: historyEntry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: entry.bodyText,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: entry.bodyText,
|
||||
CommandBody: entry.commandBody,
|
||||
BodyForCommands: entry.commandBody,
|
||||
From: entry.isGroup
|
||||
? `group:${entry.groupId ?? "unknown"}`
|
||||
: `signal:${entry.senderRecipient}`,
|
||||
To: signalTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: entry.isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: entry.isGroup ? (entry.groupName ?? undefined) : undefined,
|
||||
SenderName: entry.senderName,
|
||||
SenderId: entry.senderDisplay,
|
||||
Provider: "signal" as const,
|
||||
Surface: "signal" as const,
|
||||
MessageSid: entry.messageId,
|
||||
Timestamp: entry.timestamp ?? undefined,
|
||||
MediaPath: entry.mediaPath,
|
||||
MediaType: entry.mediaType,
|
||||
MediaUrl: entry.mediaPath,
|
||||
MediaPaths: entry.mediaPaths,
|
||||
MediaUrls: entry.mediaPaths,
|
||||
MediaTypes: entry.mediaTypes,
|
||||
WasMentioned: entry.isGroup ? entry.wasMentioned === true : undefined,
|
||||
CommandAuthorized: entry.commandAuthorized,
|
||||
OriginatingChannel: "signal" as const,
|
||||
OriginatingTo: signalTo,
|
||||
});
|
||||
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: !entry.isGroup
|
||||
? {
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "signal",
|
||||
to: entry.senderRecipient,
|
||||
accountId: route.accountId,
|
||||
mainDmOwnerPin: (() => {
|
||||
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: deps.cfg.session?.dmScope,
|
||||
allowFrom: deps.allowFrom,
|
||||
normalizeEntry: normalizeSignalAllowRecipient,
|
||||
});
|
||||
if (!pinnedOwner) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ownerRecipient: pinnedOwner,
|
||||
senderRecipient: entry.senderRecipient,
|
||||
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
||||
logVerbose(
|
||||
`signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
||||
);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
}
|
||||
: undefined,
|
||||
onRecordError: (err) => {
|
||||
logVerbose(`signal: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = body.slice(0, 200).replace(/\\n/g, "\\\\n");
|
||||
logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
|
||||
}
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: deps.cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "signal",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: async () => {
|
||||
if (!ctxPayload.To) {
|
||||
return;
|
||||
}
|
||||
await sendTypingSignal(ctxPayload.To, {
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
accountId: deps.accountId,
|
||||
});
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerbose,
|
||||
channel: "signal",
|
||||
target: ctxPayload.To ?? undefined,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
await deps.deliverReplies({
|
||||
replies: [payload],
|
||||
target: ctxPayload.To,
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
accountId: deps.accountId,
|
||||
runtime: deps.runtime,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
textLimit: deps.textLimit,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
});
|
||||
|
||||
const { queuedFinal } = await dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg: deps.cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
if (!queuedFinal) {
|
||||
if (entry.isGroup && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey,
|
||||
limit: deps.historyLimit,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entry.isGroup && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey,
|
||||
limit: deps.historyLimit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<SignalInboundEntry>({
|
||||
cfg: deps.cfg,
|
||||
channel: "signal",
|
||||
buildKey: (entry) => {
|
||||
const conversationId = entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId;
|
||||
if (!conversationId || !entry.senderPeerId) {
|
||||
return null;
|
||||
}
|
||||
return `signal:${deps.accountId}:${conversationId}:${entry.senderPeerId}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
return shouldDebounceTextInbound({
|
||||
text: entry.bodyText,
|
||||
cfg: deps.cfg,
|
||||
hasMedia: Boolean(entry.mediaPath || entry.mediaType || entry.mediaPaths?.length),
|
||||
});
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await handleSignalInboundMessage(last);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.bodyText)
|
||||
.filter(Boolean)
|
||||
.join("\\n");
|
||||
if (!combinedText.trim()) {
|
||||
return;
|
||||
}
|
||||
await handleSignalInboundMessage({
|
||||
...last,
|
||||
bodyText: combinedText,
|
||||
mediaPath: undefined,
|
||||
mediaType: undefined,
|
||||
mediaPaths: undefined,
|
||||
mediaTypes: undefined,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
deps.runtime.error?.(`signal debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
function handleReactionOnlyInbound(params: {
|
||||
envelope: SignalEnvelope;
|
||||
sender: SignalSender;
|
||||
senderDisplay: string;
|
||||
reaction: SignalReactionMessage;
|
||||
hasBodyContent: boolean;
|
||||
resolveAccessDecision: (isGroup: boolean) => {
|
||||
decision: "allow" | "block" | "pairing";
|
||||
reason: string;
|
||||
};
|
||||
}): boolean {
|
||||
if (params.hasBodyContent) {
|
||||
return false;
|
||||
}
|
||||
if (params.reaction.isRemove) {
|
||||
return true; // Ignore reaction removals
|
||||
}
|
||||
const emojiLabel = params.reaction.emoji?.trim() || "emoji";
|
||||
const senderName = params.envelope.sourceName ?? params.senderDisplay;
|
||||
logVerbose(`signal reaction: ${emojiLabel} from ${senderName}`);
|
||||
const groupId = params.reaction.groupInfo?.groupId ?? undefined;
|
||||
const groupName = params.reaction.groupInfo?.groupName ?? undefined;
|
||||
const isGroup = Boolean(groupId);
|
||||
const reactionAccess = params.resolveAccessDecision(isGroup);
|
||||
if (reactionAccess.decision !== "allow") {
|
||||
logVerbose(
|
||||
`Blocked signal reaction sender ${params.senderDisplay} (${reactionAccess.reason})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const targets = deps.resolveSignalReactionTargets(params.reaction);
|
||||
const shouldNotify = deps.shouldEmitSignalReactionNotification({
|
||||
mode: deps.reactionMode,
|
||||
account: deps.account,
|
||||
targets,
|
||||
sender: params.sender,
|
||||
allowlist: deps.reactionAllowlist,
|
||||
});
|
||||
if (!shouldNotify) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const senderPeerId = resolveSignalPeerId(params.sender);
|
||||
const route = resolveSignalInboundRoute({
|
||||
cfg: deps.cfg,
|
||||
accountId: deps.accountId,
|
||||
isGroup,
|
||||
groupId,
|
||||
senderPeerId,
|
||||
});
|
||||
const groupLabel = isGroup ? `${groupName ?? "Signal Group"} id:${groupId}` : undefined;
|
||||
const messageId = params.reaction.targetSentTimestamp
|
||||
? String(params.reaction.targetSentTimestamp)
|
||||
: "unknown";
|
||||
const text = deps.buildSignalReactionSystemEventText({
|
||||
emojiLabel,
|
||||
actorLabel: senderName,
|
||||
messageId,
|
||||
targetLabel: targets[0]?.display,
|
||||
groupLabel,
|
||||
});
|
||||
const senderId = formatSignalSenderId(params.sender);
|
||||
const contextKey = [
|
||||
"signal",
|
||||
"reaction",
|
||||
"added",
|
||||
messageId,
|
||||
senderId,
|
||||
emojiLabel,
|
||||
groupId ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":");
|
||||
enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey });
|
||||
return true;
|
||||
}
|
||||
|
||||
return async (event: { event?: string; data?: string }) => {
|
||||
if (event.event !== "receive" || !event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: SignalReceivePayload | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data) as SignalReceivePayload;
|
||||
} catch (err) {
|
||||
deps.runtime.error?.(`failed to parse event: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (payload?.exception?.message) {
|
||||
deps.runtime.error?.(`receive exception: ${payload.exception.message}`);
|
||||
}
|
||||
const envelope = payload?.envelope;
|
||||
if (!envelope) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for syncMessage (e.g., sentTranscript from other devices)
|
||||
// We need to check if it's from our own account to prevent self-reply loops
|
||||
const sender = resolveSignalSender(envelope);
|
||||
if (!sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the message is from our own account to prevent loop/self-reply
|
||||
// This handles both phone number and UUID based identification
|
||||
const normalizedAccount = deps.account ? normalizeE164(deps.account) : undefined;
|
||||
const isOwnMessage =
|
||||
(sender.kind === "phone" && normalizedAccount != null && sender.e164 === normalizedAccount) ||
|
||||
(sender.kind === "uuid" && deps.accountUuid != null && sender.raw === deps.accountUuid);
|
||||
if (isOwnMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter all sync messages (sentTranscript, readReceipts, etc.).
|
||||
// signal-cli may set syncMessage to null instead of omitting it, so
|
||||
// check property existence rather than truthiness to avoid replaying
|
||||
// the bot's own sent messages on daemon restart.
|
||||
if ("syncMessage" in envelope) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage;
|
||||
const reaction = deps.isSignalReactionMessage(envelope.reactionMessage)
|
||||
? envelope.reactionMessage
|
||||
: deps.isSignalReactionMessage(dataMessage?.reaction)
|
||||
? dataMessage?.reaction
|
||||
: null;
|
||||
|
||||
// Replace  (object replacement character) with @uuid or @phone from mentions
|
||||
// Signal encodes mentions as the object replacement character; hydrate them from metadata first.
|
||||
const rawMessage = dataMessage?.message ?? "";
|
||||
const normalizedMessage = renderSignalMentions(rawMessage, dataMessage?.mentions);
|
||||
const messageText = normalizedMessage.trim();
|
||||
|
||||
const quoteText = dataMessage?.quote?.text?.trim() ?? "";
|
||||
const hasBodyContent =
|
||||
Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length);
|
||||
const senderDisplay = formatSignalSenderDisplay(sender);
|
||||
const { resolveAccessDecision, dmAccess, effectiveDmAllow, effectiveGroupAllow } =
|
||||
await resolveSignalAccessState({
|
||||
accountId: deps.accountId,
|
||||
dmPolicy: deps.dmPolicy,
|
||||
groupPolicy: deps.groupPolicy,
|
||||
allowFrom: deps.allowFrom,
|
||||
groupAllowFrom: deps.groupAllowFrom,
|
||||
sender,
|
||||
});
|
||||
|
||||
if (
|
||||
reaction &&
|
||||
handleReactionOnlyInbound({
|
||||
envelope,
|
||||
sender,
|
||||
senderDisplay,
|
||||
reaction,
|
||||
hasBodyContent,
|
||||
resolveAccessDecision,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!dataMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const senderRecipient = resolveSignalRecipient(sender);
|
||||
const senderPeerId = resolveSignalPeerId(sender);
|
||||
const senderAllowId = formatSignalSenderId(sender);
|
||||
if (!senderRecipient) {
|
||||
return;
|
||||
}
|
||||
const senderIdLine = formatSignalPairingIdLine(sender);
|
||||
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
|
||||
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
|
||||
const isGroup = Boolean(groupId);
|
||||
|
||||
if (!isGroup) {
|
||||
const allowedDirectMessage = await handleSignalDirectMessageAccess({
|
||||
dmPolicy: deps.dmPolicy,
|
||||
dmAccessDecision: dmAccess.decision,
|
||||
senderId: senderAllowId,
|
||||
senderIdLine,
|
||||
senderDisplay,
|
||||
senderName: envelope.sourceName ?? undefined,
|
||||
accountId: deps.accountId,
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageSignal(`signal:${senderRecipient}`, text, {
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
accountId: deps.accountId,
|
||||
});
|
||||
},
|
||||
log: logVerbose,
|
||||
});
|
||||
if (!allowedDirectMessage) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isGroup) {
|
||||
const groupAccess = resolveAccessDecision(true);
|
||||
if (groupAccess.decision !== "allow") {
|
||||
if (groupAccess.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
|
||||
logVerbose("Blocked signal group message (groupPolicy: disabled)");
|
||||
} else if (groupAccess.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
|
||||
logVerbose("Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)");
|
||||
} else {
|
||||
logVerbose(`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllow = isGroup ? deps.allowFrom : effectiveDmAllow;
|
||||
const ownerAllowedForCommands = isSignalSenderAllowed(sender, commandDmAllow);
|
||||
const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow);
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandDmAllow.length > 0, allowed: ownerAllowedForCommands },
|
||||
{ configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands },
|
||||
],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
if (isGroup && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "signal",
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderDisplay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const route = resolveSignalInboundRoute({
|
||||
cfg: deps.cfg,
|
||||
accountId: deps.accountId,
|
||||
isGroup,
|
||||
groupId,
|
||||
senderPeerId,
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(deps.cfg, route.agentId);
|
||||
const wasMentioned = isGroup && matchesMentionPatterns(messageText, mentionRegexes);
|
||||
const requireMention =
|
||||
isGroup &&
|
||||
resolveChannelGroupRequireMention({
|
||||
cfg: deps.cfg,
|
||||
channel: "signal",
|
||||
groupId,
|
||||
accountId: deps.accountId,
|
||||
});
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention,
|
||||
wasMentioned,
|
||||
implicitMention: false,
|
||||
hasAnyMention: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
commandAuthorized,
|
||||
});
|
||||
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||
if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "signal",
|
||||
reason: "no mention",
|
||||
target: senderDisplay,
|
||||
});
|
||||
const quoteText = dataMessage.quote?.text?.trim() || "";
|
||||
const pendingPlaceholder = (() => {
|
||||
if (!dataMessage.attachments?.length) {
|
||||
return "";
|
||||
}
|
||||
// When we're skipping a message we intentionally avoid downloading attachments.
|
||||
// Still record a useful placeholder for pending-history context.
|
||||
if (deps.ignoreAttachments) {
|
||||
return "<media:attachment>";
|
||||
}
|
||||
const attachmentTypes = (dataMessage.attachments ?? []).map((attachment) =>
|
||||
typeof attachment?.contentType === "string" ? attachment.contentType : undefined,
|
||||
);
|
||||
if (attachmentTypes.length > 1) {
|
||||
return formatAttachmentSummaryPlaceholder(attachmentTypes);
|
||||
}
|
||||
const firstContentType = dataMessage.attachments?.[0]?.contentType;
|
||||
const pendingKind = kindFromMime(firstContentType ?? undefined);
|
||||
return pendingKind ? `<media:${pendingKind}>` : "<media:attachment>";
|
||||
})();
|
||||
const pendingBodyText = messageText || pendingPlaceholder || quoteText;
|
||||
const historyKey = groupId ?? "unknown";
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: deps.groupHistories,
|
||||
historyKey,
|
||||
limit: deps.historyLimit,
|
||||
entry: {
|
||||
sender: envelope.sourceName ?? senderDisplay,
|
||||
body: pendingBodyText,
|
||||
timestamp: envelope.timestamp ?? undefined,
|
||||
messageId:
|
||||
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
const mediaPaths: string[] = [];
|
||||
const mediaTypes: string[] = [];
|
||||
let placeholder = "";
|
||||
const attachments = dataMessage.attachments ?? [];
|
||||
if (!deps.ignoreAttachments) {
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment?.id) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const fetched = await deps.fetchAttachment({
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
attachment,
|
||||
sender: senderRecipient,
|
||||
groupId,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
});
|
||||
if (fetched) {
|
||||
mediaPaths.push(fetched.path);
|
||||
mediaTypes.push(
|
||||
fetched.contentType ?? attachment.contentType ?? "application/octet-stream",
|
||||
);
|
||||
if (!mediaPath) {
|
||||
mediaPath = fetched.path;
|
||||
mediaType = fetched.contentType ?? attachment.contentType ?? undefined;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
deps.runtime.error?.(danger(`attachment fetch failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaPaths.length > 1) {
|
||||
placeholder = formatAttachmentSummaryPlaceholder(mediaTypes);
|
||||
} else {
|
||||
const kind = kindFromMime(mediaType ?? undefined);
|
||||
if (kind) {
|
||||
placeholder = `<media:${kind}>`;
|
||||
} else if (attachments.length) {
|
||||
placeholder = "<media:attachment>";
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
|
||||
if (!bodyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const receiptTimestamp =
|
||||
typeof envelope.timestamp === "number"
|
||||
? envelope.timestamp
|
||||
: typeof dataMessage.timestamp === "number"
|
||||
? dataMessage.timestamp
|
||||
: undefined;
|
||||
if (deps.sendReadReceipts && !deps.readReceiptsViaDaemon && !isGroup && receiptTimestamp) {
|
||||
try {
|
||||
await sendReadReceiptSignal(`signal:${senderRecipient}`, receiptTimestamp, {
|
||||
baseUrl: deps.baseUrl,
|
||||
account: deps.account,
|
||||
accountId: deps.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`signal read receipt failed for ${senderDisplay}: ${String(err)}`);
|
||||
}
|
||||
} else if (
|
||||
deps.sendReadReceipts &&
|
||||
!deps.readReceiptsViaDaemon &&
|
||||
!isGroup &&
|
||||
!receiptTimestamp
|
||||
) {
|
||||
logVerbose(`signal read receipt skipped (missing timestamp) for ${senderDisplay}`);
|
||||
}
|
||||
|
||||
const senderName = envelope.sourceName ?? senderDisplay;
|
||||
const messageId =
|
||||
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined;
|
||||
await inboundDebouncer.enqueue({
|
||||
senderName,
|
||||
senderDisplay,
|
||||
senderRecipient,
|
||||
senderPeerId,
|
||||
groupId,
|
||||
groupName,
|
||||
isGroup,
|
||||
bodyText,
|
||||
commandBody: messageText,
|
||||
timestamp: envelope.timestamp ?? undefined,
|
||||
messageId,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
mediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
mediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
commandAuthorized,
|
||||
wasMentioned: effectiveWasMentioned,
|
||||
});
|
||||
};
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/monitor/event-handler
|
||||
export * from "../../../extensions/signal/src/monitor/event-handler.js";
|
||||
|
||||
@@ -1,127 +1,2 @@
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { DmPolicy, GroupPolicy, SignalReactionNotificationMode } from "../../config/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { SignalSender } from "../identity.js";
|
||||
|
||||
export type SignalEnvelope = {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
sourceName?: string | null;
|
||||
timestamp?: number | null;
|
||||
dataMessage?: SignalDataMessage | null;
|
||||
editMessage?: { dataMessage?: SignalDataMessage | null } | null;
|
||||
syncMessage?: unknown;
|
||||
reactionMessage?: SignalReactionMessage | null;
|
||||
};
|
||||
|
||||
export type SignalMention = {
|
||||
name?: string | null;
|
||||
number?: string | null;
|
||||
uuid?: string | null;
|
||||
start?: number | null;
|
||||
length?: number | null;
|
||||
};
|
||||
|
||||
export type SignalDataMessage = {
|
||||
timestamp?: number;
|
||||
message?: string | null;
|
||||
attachments?: Array<SignalAttachment>;
|
||||
mentions?: Array<SignalMention> | null;
|
||||
groupInfo?: {
|
||||
groupId?: string | null;
|
||||
groupName?: string | null;
|
||||
} | null;
|
||||
quote?: { text?: string | null } | null;
|
||||
reaction?: SignalReactionMessage | null;
|
||||
};
|
||||
|
||||
export type SignalReactionMessage = {
|
||||
emoji?: string | null;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
targetSentTimestamp?: number | null;
|
||||
isRemove?: boolean | null;
|
||||
groupInfo?: {
|
||||
groupId?: string | null;
|
||||
groupName?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SignalAttachment = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
filename?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export type SignalReactionTarget = {
|
||||
kind: "phone" | "uuid";
|
||||
id: string;
|
||||
display: string;
|
||||
};
|
||||
|
||||
export type SignalReceivePayload = {
|
||||
envelope?: SignalEnvelope | null;
|
||||
exception?: { message?: string } | null;
|
||||
};
|
||||
|
||||
export type SignalEventHandlerDeps = {
|
||||
runtime: RuntimeEnv;
|
||||
cfg: OpenClawConfig;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountUuid?: string;
|
||||
accountId: string;
|
||||
blockStreaming?: boolean;
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
textLimit: number;
|
||||
dmPolicy: DmPolicy;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
groupPolicy: GroupPolicy;
|
||||
reactionMode: SignalReactionNotificationMode;
|
||||
reactionAllowlist: string[];
|
||||
mediaMaxBytes: number;
|
||||
ignoreAttachments: boolean;
|
||||
sendReadReceipts: boolean;
|
||||
readReceiptsViaDaemon: boolean;
|
||||
fetchAttachment: (params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
attachment: SignalAttachment;
|
||||
sender?: string;
|
||||
groupId?: string;
|
||||
maxBytes: number;
|
||||
}) => Promise<{ path: string; contentType?: string } | null>;
|
||||
deliverReplies: (params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
}) => Promise<void>;
|
||||
resolveSignalReactionTargets: (reaction: SignalReactionMessage) => SignalReactionTarget[];
|
||||
isSignalReactionMessage: (
|
||||
reaction: SignalReactionMessage | null | undefined,
|
||||
) => reaction is SignalReactionMessage;
|
||||
shouldEmitSignalReactionNotification: (params: {
|
||||
mode?: SignalReactionNotificationMode;
|
||||
account?: string | null;
|
||||
targets?: SignalReactionTarget[];
|
||||
sender?: SignalSender | null;
|
||||
allowlist?: string[];
|
||||
}) => boolean;
|
||||
buildSignalReactionSystemEventText: (params: {
|
||||
emojiLabel: string;
|
||||
actorLabel: string;
|
||||
messageId: string;
|
||||
targetLabel?: string;
|
||||
groupLabel?: string;
|
||||
}) => string;
|
||||
};
|
||||
// Shim: re-exports from extensions/signal/src/monitor/event-handler.types
|
||||
export * from "../../../extensions/signal/src/monitor/event-handler.types.js";
|
||||
|
||||
@@ -1,56 +1,2 @@
|
||||
import type { SignalMention } from "./event-handler.types.js";
|
||||
|
||||
const OBJECT_REPLACEMENT = "\uFFFC";
|
||||
|
||||
function isValidMention(mention: SignalMention | null | undefined): mention is SignalMention {
|
||||
if (!mention) {
|
||||
return false;
|
||||
}
|
||||
if (!(mention.uuid || mention.number)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof mention.start !== "number" || Number.isNaN(mention.start)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof mention.length !== "number" || Number.isNaN(mention.length)) {
|
||||
return false;
|
||||
}
|
||||
return mention.length > 0;
|
||||
}
|
||||
|
||||
function clampBounds(start: number, length: number, textLength: number) {
|
||||
const safeStart = Math.max(0, Math.trunc(start));
|
||||
const safeLength = Math.max(0, Math.trunc(length));
|
||||
const safeEnd = Math.min(textLength, safeStart + safeLength);
|
||||
return { start: safeStart, end: safeEnd };
|
||||
}
|
||||
|
||||
export function renderSignalMentions(message: string, mentions?: SignalMention[] | null) {
|
||||
if (!message || !mentions?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
let normalized = message;
|
||||
const candidates = mentions.filter(isValidMention).toSorted((a, b) => b.start! - a.start!);
|
||||
|
||||
for (const mention of candidates) {
|
||||
const identifier = mention.uuid ?? mention.number;
|
||||
if (!identifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { start, end } = clampBounds(mention.start!, mention.length!, normalized.length);
|
||||
if (start >= end) {
|
||||
continue;
|
||||
}
|
||||
const slice = normalized.slice(start, end);
|
||||
|
||||
if (!slice.includes(OBJECT_REPLACEMENT)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized = normalized.slice(0, start) + `@${identifier}` + normalized.slice(end);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/monitor/mentions
|
||||
export * from "../../../extensions/signal/src/monitor/mentions.js";
|
||||
|
||||
@@ -1,69 +1,2 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { classifySignalCliLogLine } from "./daemon.js";
|
||||
import { probeSignal } from "./probe.js";
|
||||
|
||||
const signalCheckMock = vi.fn();
|
||||
const signalRpcRequestMock = vi.fn();
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
signalCheck: (...args: unknown[]) => signalCheckMock(...args),
|
||||
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
|
||||
}));
|
||||
|
||||
describe("probeSignal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("extracts version from {version} result", async () => {
|
||||
signalCheckMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
error: null,
|
||||
});
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ version: "0.13.22" });
|
||||
|
||||
const res = await probeSignal("http://127.0.0.1:8080", 1000);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.version).toBe("0.13.22");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns ok=false when /check fails", async () => {
|
||||
signalCheckMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
error: "HTTP 503",
|
||||
});
|
||||
|
||||
const res = await probeSignal("http://127.0.0.1:8080", 1000);
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.version).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifySignalCliLogLine", () => {
|
||||
it("treats INFO/DEBUG as log (even if emitted on stderr)", () => {
|
||||
expect(classifySignalCliLogLine("INFO DaemonCommand - Started")).toBe("log");
|
||||
expect(classifySignalCliLogLine("DEBUG Something")).toBe("log");
|
||||
});
|
||||
|
||||
it("treats WARN/ERROR as error", () => {
|
||||
expect(classifySignalCliLogLine("WARN Something")).toBe("error");
|
||||
expect(classifySignalCliLogLine("WARNING Something")).toBe("error");
|
||||
expect(classifySignalCliLogLine("ERROR Something")).toBe("error");
|
||||
});
|
||||
|
||||
it("treats failures without explicit severity as error", () => {
|
||||
expect(classifySignalCliLogLine("Failed to initialize HTTP Server - oops")).toBe("error");
|
||||
expect(classifySignalCliLogLine('Exception in thread "main"')).toBe("error");
|
||||
});
|
||||
|
||||
it("returns null for empty lines", () => {
|
||||
expect(classifySignalCliLogLine("")).toBe(null);
|
||||
expect(classifySignalCliLogLine(" ")).toBe(null);
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/probe.test
|
||||
export * from "../../extensions/signal/src/probe.test.js";
|
||||
|
||||
@@ -1,56 +1,2 @@
|
||||
import type { BaseProbeResult } from "../channels/plugins/types.js";
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
|
||||
export type SignalProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
elapsedMs: number;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
function parseSignalVersion(value: unknown): string | null {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const version = (value as { version?: unknown }).version;
|
||||
if (typeof version === "string" && version.trim()) {
|
||||
return version.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function probeSignal(baseUrl: string, timeoutMs: number): Promise<SignalProbe> {
|
||||
const started = Date.now();
|
||||
const result: SignalProbe = {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: null,
|
||||
elapsedMs: 0,
|
||||
version: null,
|
||||
};
|
||||
const check = await signalCheck(baseUrl, timeoutMs);
|
||||
if (!check.ok) {
|
||||
return {
|
||||
...result,
|
||||
status: check.status ?? null,
|
||||
error: check.error ?? "unreachable",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const version = await signalRpcRequest("version", undefined, {
|
||||
baseUrl,
|
||||
timeoutMs,
|
||||
});
|
||||
result.version = parseSignalVersion(version);
|
||||
} catch (err) {
|
||||
result.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
ok: true,
|
||||
status: check.status ?? null,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/probe
|
||||
export * from "../../extensions/signal/src/probe.js";
|
||||
|
||||
@@ -1,34 +1,2 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveReactionLevel,
|
||||
type ReactionLevel,
|
||||
type ResolvedReactionLevel,
|
||||
} from "../utils/reaction-level.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
|
||||
export type SignalReactionLevel = ReactionLevel;
|
||||
export type ResolvedSignalReactionLevel = ResolvedReactionLevel;
|
||||
|
||||
/**
|
||||
* 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: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedSignalReactionLevel {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return resolveReactionLevel({
|
||||
value: account.config.reactionLevel,
|
||||
defaultLevel: "minimal",
|
||||
invalidFallback: "minimal",
|
||||
});
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/reaction-level
|
||||
export * from "../../extensions/signal/src/reaction-level.js";
|
||||
|
||||
@@ -1,24 +1,2 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
|
||||
export function resolveSignalRpcContext(
|
||||
opts: { baseUrl?: string; account?: string; accountId?: string },
|
||||
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 };
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/rpc-context
|
||||
export * from "../../extensions/signal/src/rpc-context.js";
|
||||
|
||||
@@ -1,65 +1,2 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js";
|
||||
|
||||
const rpcMock = vi.fn();
|
||||
|
||||
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.mockClear().mockResolvedValue({ timestamp: 123 });
|
||||
});
|
||||
|
||||
it("uses recipients array and targetAuthor for uuid dms", async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
// Shim: re-exports from extensions/signal/src/send-reactions.test
|
||||
export * from "../../extensions/signal/src/send-reactions.test.js";
|
||||
|
||||
@@ -1,190 +1,2 @@
|
||||
/**
|
||||
* Signal reactions via signal-cli JSON-RPC API
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
|
||||
export type SignalReactionOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
targetAuthor?: string;
|
||||
targetAuthorUuid?: string;
|
||||
groupId?: string;
|
||||
};
|
||||
|
||||
export type SignalReactionResult = {
|
||||
ok: boolean;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
type SignalReactionErrorMessages = {
|
||||
missingRecipient: string;
|
||||
invalidTargetTimestamp: string;
|
||||
missingEmoji: string;
|
||||
missingTargetAuthor: string;
|
||||
};
|
||||
|
||||
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 {};
|
||||
}
|
||||
|
||||
async function sendReactionSignalCore(params: {
|
||||
recipient: string;
|
||||
targetTimestamp: number;
|
||||
emoji: string;
|
||||
remove: boolean;
|
||||
opts: SignalReactionOpts;
|
||||
errors: SignalReactionErrorMessages;
|
||||
}): Promise<SignalReactionResult> {
|
||||
const cfg = params.opts.cfg ?? loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: params.opts.accountId,
|
||||
});
|
||||
const { baseUrl, account } = resolveSignalRpcContext(params.opts, accountInfo);
|
||||
|
||||
const normalizedRecipient = normalizeSignalUuid(params.recipient);
|
||||
const groupId = params.opts.groupId?.trim();
|
||||
if (!normalizedRecipient && !groupId) {
|
||||
throw new Error(params.errors.missingRecipient);
|
||||
}
|
||||
if (!Number.isFinite(params.targetTimestamp) || params.targetTimestamp <= 0) {
|
||||
throw new Error(params.errors.invalidTargetTimestamp);
|
||||
}
|
||||
const normalizedEmoji = params.emoji?.trim();
|
||||
if (!normalizedEmoji) {
|
||||
throw new Error(params.errors.missingEmoji);
|
||||
}
|
||||
|
||||
const targetAuthorParams = resolveTargetAuthorParams({
|
||||
targetAuthor: params.opts.targetAuthor,
|
||||
targetAuthorUuid: params.opts.targetAuthorUuid,
|
||||
fallback: normalizedRecipient,
|
||||
});
|
||||
if (groupId && !targetAuthorParams.targetAuthor) {
|
||||
throw new Error(params.errors.missingTargetAuthor);
|
||||
}
|
||||
|
||||
const requestParams: Record<string, unknown> = {
|
||||
emoji: normalizedEmoji,
|
||||
targetTimestamp: params.targetTimestamp,
|
||||
...(params.remove ? { remove: true } : {}),
|
||||
...targetAuthorParams,
|
||||
};
|
||||
if (normalizedRecipient) {
|
||||
requestParams.recipients = [normalizedRecipient];
|
||||
}
|
||||
if (groupId) {
|
||||
requestParams.groupIds = [groupId];
|
||||
}
|
||||
if (account) {
|
||||
requestParams.account = account;
|
||||
}
|
||||
|
||||
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", requestParams, {
|
||||
baseUrl,
|
||||
timeoutMs: params.opts.timeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
timestamp: result?.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return await sendReactionSignalCore({
|
||||
recipient,
|
||||
targetTimestamp,
|
||||
emoji,
|
||||
remove: false,
|
||||
opts,
|
||||
errors: {
|
||||
missingRecipient: "Recipient or groupId is required for Signal reaction",
|
||||
invalidTargetTimestamp: "Valid targetTimestamp is required for Signal reaction",
|
||||
missingEmoji: "Emoji is required for Signal reaction",
|
||||
missingTargetAuthor: "targetAuthor is required for group reactions",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return await sendReactionSignalCore({
|
||||
recipient,
|
||||
targetTimestamp,
|
||||
emoji,
|
||||
remove: true,
|
||||
opts,
|
||||
errors: {
|
||||
missingRecipient: "Recipient or groupId is required for Signal reaction removal",
|
||||
invalidTargetTimestamp: "Valid targetTimestamp is required for Signal reaction removal",
|
||||
missingEmoji: "Emoji is required for Signal reaction removal",
|
||||
missingTargetAuthor: "targetAuthor is required for group reaction removal",
|
||||
},
|
||||
});
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/send-reactions
|
||||
export * from "../../extensions/signal/src/send-reactions.js";
|
||||
|
||||
@@ -1,249 +1,2 @@
|
||||
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { kindFromMime } from "../media/mime.js";
|
||||
import { resolveOutboundAttachmentFromUrl } from "../media/outbound-attachment.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
|
||||
export type SignalSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
textMode?: "markdown" | "plain";
|
||||
textStyles?: SignalTextStyleRange[];
|
||||
};
|
||||
|
||||
export type SignalSendResult = {
|
||||
messageId: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
export type SignalRpcOpts = Pick<SignalSendOpts, "baseUrl" | "account" | "accountId" | "timeoutMs">;
|
||||
|
||||
export type SignalReceiptType = "read" | "viewed";
|
||||
|
||||
type SignalTarget =
|
||||
| { type: "recipient"; recipient: string }
|
||||
| { type: "group"; groupId: string }
|
||||
| { type: "username"; username: string };
|
||||
|
||||
function parseTarget(raw: string): SignalTarget {
|
||||
let value = raw.trim();
|
||||
if (!value) {
|
||||
throw new Error("Signal recipient is required");
|
||||
}
|
||||
const lower = value.toLowerCase();
|
||||
if (lower.startsWith("signal:")) {
|
||||
value = value.slice("signal:".length).trim();
|
||||
}
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.startsWith("group:")) {
|
||||
return { type: "group", groupId: value.slice("group:".length).trim() };
|
||||
}
|
||||
if (normalized.startsWith("username:")) {
|
||||
return {
|
||||
type: "username",
|
||||
username: value.slice("username:".length).trim(),
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith("u:")) {
|
||||
return { type: "username", username: value.trim() };
|
||||
}
|
||||
return { type: "recipient", recipient: value };
|
||||
}
|
||||
|
||||
type SignalTargetParams = {
|
||||
recipient?: string[];
|
||||
groupId?: string;
|
||||
username?: string[];
|
||||
};
|
||||
|
||||
type SignalTargetAllowlist = {
|
||||
recipient?: boolean;
|
||||
group?: boolean;
|
||||
username?: boolean;
|
||||
};
|
||||
|
||||
function buildTargetParams(
|
||||
target: SignalTarget,
|
||||
allow: SignalTargetAllowlist,
|
||||
): SignalTargetParams | null {
|
||||
if (target.type === "recipient") {
|
||||
if (!allow.recipient) {
|
||||
return null;
|
||||
}
|
||||
return { recipient: [target.recipient] };
|
||||
}
|
||||
if (target.type === "group") {
|
||||
if (!allow.group) {
|
||||
return null;
|
||||
}
|
||||
return { groupId: target.groupId };
|
||||
}
|
||||
if (target.type === "username") {
|
||||
if (!allow.username) {
|
||||
return null;
|
||||
}
|
||||
return { username: [target.username] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function sendMessageSignal(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: SignalSendOpts = {},
|
||||
): Promise<SignalSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
|
||||
const target = parseTarget(to);
|
||||
let message = text ?? "";
|
||||
let messageFromPlaceholder = false;
|
||||
let textStyles: SignalTextStyleRange[] = [];
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
const maxBytes = (() => {
|
||||
if (typeof opts.maxBytes === "number") {
|
||||
return opts.maxBytes;
|
||||
}
|
||||
if (typeof accountInfo.config.mediaMaxMb === "number") {
|
||||
return accountInfo.config.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
if (typeof cfg.agents?.defaults?.mediaMaxMb === "number") {
|
||||
return cfg.agents.defaults.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
return 8 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
let attachments: string[] | undefined;
|
||||
if (opts.mediaUrl?.trim()) {
|
||||
const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes, {
|
||||
localRoots: opts.mediaLocalRoots,
|
||||
});
|
||||
attachments = [resolved.path];
|
||||
const kind = kindFromMime(resolved.contentType ?? undefined);
|
||||
if (!message && kind) {
|
||||
// Avoid sending an empty body when only attachments exist.
|
||||
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
|
||||
messageFromPlaceholder = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.trim() && !messageFromPlaceholder) {
|
||||
if (textMode === "plain") {
|
||||
textStyles = opts.textStyles ?? [];
|
||||
} else {
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const formatted = markdownToSignalText(message, { tableMode });
|
||||
message = formatted.text;
|
||||
textStyles = formatted.styles;
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.trim() && (!attachments || attachments.length === 0)) {
|
||||
throw new Error("Signal send requires text or media");
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = { message };
|
||||
if (textStyles.length > 0) {
|
||||
params["text-style"] = textStyles.map(
|
||||
(style) => `${style.start}:${style.length}:${style.style}`,
|
||||
);
|
||||
}
|
||||
if (account) {
|
||||
params.account = account;
|
||||
}
|
||||
if (attachments && attachments.length > 0) {
|
||||
params.attachments = attachments;
|
||||
}
|
||||
|
||||
const targetParams = buildTargetParams(target, {
|
||||
recipient: true,
|
||||
group: true,
|
||||
username: true,
|
||||
});
|
||||
if (!targetParams) {
|
||||
throw new Error("Signal recipient is required");
|
||||
}
|
||||
Object.assign(params, targetParams);
|
||||
|
||||
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
|
||||
baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
const timestamp = result?.timestamp;
|
||||
return {
|
||||
messageId: timestamp ? String(timestamp) : "unknown",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendTypingSignal(
|
||||
to: string,
|
||||
opts: SignalRpcOpts & { stop?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts);
|
||||
const targetParams = buildTargetParams(parseTarget(to), {
|
||||
recipient: true,
|
||||
group: true,
|
||||
});
|
||||
if (!targetParams) {
|
||||
return false;
|
||||
}
|
||||
const params: Record<string, unknown> = { ...targetParams };
|
||||
if (account) {
|
||||
params.account = account;
|
||||
}
|
||||
if (opts.stop) {
|
||||
params.stop = true;
|
||||
}
|
||||
await signalRpcRequest("sendTyping", params, {
|
||||
baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function sendReadReceiptSignal(
|
||||
to: string,
|
||||
targetTimestamp: number,
|
||||
opts: SignalRpcOpts & { type?: SignalReceiptType } = {},
|
||||
): Promise<boolean> {
|
||||
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
|
||||
return false;
|
||||
}
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts);
|
||||
const targetParams = buildTargetParams(parseTarget(to), {
|
||||
recipient: true,
|
||||
});
|
||||
if (!targetParams) {
|
||||
return false;
|
||||
}
|
||||
const params: Record<string, unknown> = {
|
||||
...targetParams,
|
||||
targetTimestamp,
|
||||
type: opts.type ?? "read",
|
||||
};
|
||||
if (account) {
|
||||
params.account = account;
|
||||
}
|
||||
await signalRpcRequest("sendReceipt", params, {
|
||||
baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/send
|
||||
export * from "../../extensions/signal/src/send.js";
|
||||
|
||||
@@ -1,80 +1,2 @@
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import type { BackoffPolicy } from "../infra/backoff.js";
|
||||
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { type SignalSseEvent, streamSignalEvents } from "./client.js";
|
||||
|
||||
const DEFAULT_RECONNECT_POLICY: BackoffPolicy = {
|
||||
initialMs: 1_000,
|
||||
maxMs: 10_000,
|
||||
factor: 2,
|
||||
jitter: 0.2,
|
||||
};
|
||||
|
||||
type RunSignalSseLoopParams = {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
runtime: RuntimeEnv;
|
||||
onEvent: (event: SignalSseEvent) => void;
|
||||
policy?: Partial<BackoffPolicy>;
|
||||
};
|
||||
|
||||
export async function runSignalSseLoop({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal,
|
||||
runtime,
|
||||
onEvent,
|
||||
policy,
|
||||
}: RunSignalSseLoopParams) {
|
||||
const reconnectPolicy = {
|
||||
...DEFAULT_RECONNECT_POLICY,
|
||||
...policy,
|
||||
};
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
const logReconnectVerbose = (message: string) => {
|
||||
if (!shouldLogVerbose()) {
|
||||
return;
|
||||
}
|
||||
logVerbose(message);
|
||||
};
|
||||
|
||||
while (!abortSignal?.aborted) {
|
||||
try {
|
||||
await streamSignalEvents({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal,
|
||||
onEvent: (event) => {
|
||||
reconnectAttempts = 0;
|
||||
onEvent(event);
|
||||
},
|
||||
});
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
reconnectAttempts += 1;
|
||||
const delayMs = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||
logReconnectVerbose(`Signal SSE stream ended, reconnecting in ${delayMs / 1000}s...`);
|
||||
await sleepWithAbort(delayMs, abortSignal);
|
||||
} catch (err) {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
runtime.error?.(`Signal SSE stream error: ${String(err)}`);
|
||||
reconnectAttempts += 1;
|
||||
const delayMs = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||
runtime.log?.(`Signal SSE connection lost, reconnecting in ${delayMs / 1000}s...`);
|
||||
try {
|
||||
await sleepWithAbort(delayMs, abortSignal);
|
||||
} catch (sleepErr) {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
throw sleepErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shim: re-exports from extensions/signal/src/sse-reconnect
|
||||
export * from "../../extensions/signal/src/sse-reconnect.js";
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"noEmit": false,
|
||||
"noEmitOnError": true,
|
||||
"outDir": "dist/plugin-sdk",
|
||||
"rootDir": "src",
|
||||
"rootDir": ".",
|
||||
"tsBuildInfoFile": "dist/plugin-sdk/.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user