diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts new file mode 100644 index 000000000..edcfa4c1d --- /dev/null +++ b/extensions/signal/src/accounts.ts @@ -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); +} diff --git a/extensions/signal/src/client.test.ts b/extensions/signal/src/client.test.ts new file mode 100644 index 000000000..9313bb175 --- /dev/null +++ b/extensions/signal/src/client.test.ts @@ -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)"); + }); +}); diff --git a/extensions/signal/src/client.ts b/extensions/signal/src/client.ts new file mode 100644 index 000000000..394aec4e2 --- /dev/null +++ b/extensions/signal/src/client.ts @@ -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 = { + 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(text: string, status: number): SignalRpcResponse { + 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; + 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( + method: string, + params: Record | undefined, + opts: SignalRpcOptions, +): Promise { + 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(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 { + 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(); +} diff --git a/extensions/signal/src/daemon.ts b/extensions/signal/src/daemon.ts new file mode 100644 index 000000000..d53597a29 --- /dev/null +++ b/extensions/signal/src/daemon.ts @@ -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; + 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((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"); + } + }, + }; +} diff --git a/extensions/signal/src/format.chunking.test.ts b/extensions/signal/src/format.chunking.test.ts new file mode 100644 index 000000000..5c17ef581 --- /dev/null +++ b/extensions/signal/src/format.chunking.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from "vitest"; +import { markdownToSignalTextChunks } from "./format.js"; + +function expectChunkStyleRangesInBounds(chunks: ReturnType) { + 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, + ); + }); + }); +}); diff --git a/extensions/signal/src/format.links.test.ts b/extensions/signal/src/format.links.test.ts new file mode 100644 index 000000000..c6ec112a7 --- /dev/null +++ b/extensions/signal/src/format.links.test.ts @@ -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)"); + }); + }); +}); diff --git a/extensions/signal/src/format.test.ts b/extensions/signal/src/format.test.ts new file mode 100644 index 000000000..e22a6607f --- /dev/null +++ b/extensions/signal/src/format.test.ts @@ -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" }]); + }); +}); diff --git a/extensions/signal/src/format.ts b/extensions/signal/src/format.ts new file mode 100644 index 000000000..218069329 --- /dev/null +++ b/extensions/signal/src/format.ts @@ -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; +} diff --git a/extensions/signal/src/format.visual.test.ts b/extensions/signal/src/format.visual.test.ts new file mode 100644 index 000000000..78f913b79 --- /dev/null +++ b/extensions/signal/src/format.visual.test.ts @@ -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,}/); + }); + }); +}); diff --git a/extensions/signal/src/identity.test.ts b/extensions/signal/src/identity.test.ts new file mode 100644 index 000000000..a09f81910 --- /dev/null +++ b/extensions/signal/src/identity.test.ts @@ -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"); + }); +}); diff --git a/extensions/signal/src/identity.ts b/extensions/signal/src/identity.ts new file mode 100644 index 000000000..c39b0dd5e --- /dev/null +++ b/extensions/signal/src/identity.ts @@ -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; +} diff --git a/extensions/signal/src/index.ts b/extensions/signal/src/index.ts new file mode 100644 index 000000000..29f241149 --- /dev/null +++ b/extensions/signal/src/index.ts @@ -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"; diff --git a/extensions/signal/src/monitor.test.ts b/extensions/signal/src/monitor.test.ts new file mode 100644 index 000000000..a15956ce1 --- /dev/null +++ b/extensions/signal/src/monitor.test.ts @@ -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); + }); +}); diff --git a/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts new file mode 100644 index 000000000..72572110e --- /dev/null +++ b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts @@ -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[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; + const baseSignal = (baseChannels.signal ?? {}) as Record; + 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(); + } + }); +}); diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts new file mode 100644 index 000000000..2fedef73b --- /dev/null +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -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[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 = {}) { + setSignalToolResultTestConfig(createSignalConfig(overrides)); +} + +function createSignalConfig(overrides: Record = {}): Record { + const base = config as OpenClawConfig; + const channels = (base.channels ?? {}) as Record; + const signal = (channels.signal ?? {}) as Record; + 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; +}) { + 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 = {}) { + return { + sourceNumber: "+15550001111", + sourceName: "Ada", + timestamp: 1, + ...overrides, + }; +} + +async function receiveSingleEnvelope( + envelope: Record, + opts?: Partial, +) { + 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 = {}) { + 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((_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((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, + 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, + 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, + 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); + }); +}); diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts new file mode 100644 index 000000000..252e039b0 --- /dev/null +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -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 = {}; + +export function setSignalToolResultTestConfig(next: Record) { + config = next; +} + +export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +export function createMockSignalDaemonHandle( + overrides: { + stop?: MockFn; + exited?: Promise; + isExited?: () => boolean; + } = {}, +): SignalDaemonHandle { + const stop = overrides.stop ?? (vi.fn() as unknown as MockFn); + const exited = overrides.exited ?? new Promise(() => {}); + 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(); + 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(); + 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(); + 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(); + }); +} diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts new file mode 100644 index 000000000..3febfe740 --- /dev/null +++ b/extensions/signal/src/monitor.ts @@ -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; + groupAllowFrom?: Array; + mediaMaxMb?: number; + reconnectPolicy?: Partial; +}; + +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[] { + 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 | 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 { + 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 = { + 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 { + 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(); + 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(); + } +} diff --git a/extensions/signal/src/monitor/access-policy.ts b/extensions/signal/src/monitor/access-policy.ts new file mode 100644 index 000000000..725551860 --- /dev/null +++ b/extensions/signal/src/monitor/access-policy.ts @@ -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; + log: (message: string) => void; +}): Promise { + 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; +} diff --git a/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts new file mode 100644 index 000000000..625931567 --- /dev/null +++ b/extensions/signal/src/monitor/event-handler.inbound-contract.test.ts @@ -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 }; + }) => { + 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(); + 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(); + }); +}); diff --git a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts new file mode 100644 index 000000000..05836c439 --- /dev/null +++ b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts @@ -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 & { + 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(); + 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["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" }] }, + "", + ); + }); + + 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(""); + }); + + 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"); + }); +}); diff --git a/extensions/signal/src/monitor/event-handler.test-harness.ts b/extensions/signal/src/monitor/event-handler.test-harness.ts new file mode 100644 index 000000000..1c81dd081 --- /dev/null +++ b/extensions/signal/src/monitor/event-handler.test-harness.ts @@ -0,0 +1,49 @@ +import type { SignalEventHandlerDeps, SignalReactionMessage } from "./event-handler.types.js"; + +export function createBaseSignalEventHandlerDeps( + overrides: Partial = {}, +): 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 = {}) { + return { + event: "receive", + data: JSON.stringify({ + envelope: { + sourceNumber: "+15550001111", + sourceName: "Alice", + timestamp: 1700000000000, + ...envelopeOverrides, + }, + }), + }; +} diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts new file mode 100644 index 000000000..36eb0e8d2 --- /dev/null +++ b/extensions/signal/src/monitor/event-handler.ts @@ -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 { + const kindCounts = new Map(); + 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({ + 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 ""; + } + 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 ? `` : ""; + })(); + 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 = ``; + } else if (attachments.length) { + placeholder = ""; + } + } + + 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, + }); + }; +} diff --git a/extensions/signal/src/monitor/event-handler.types.ts b/extensions/signal/src/monitor/event-handler.types.ts new file mode 100644 index 000000000..c1d0b0b38 --- /dev/null +++ b/extensions/signal/src/monitor/event-handler.types.ts @@ -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; + mentions?: Array | 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; + 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; + 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; +}; diff --git a/extensions/signal/src/monitor/mentions.ts b/extensions/signal/src/monitor/mentions.ts new file mode 100644 index 000000000..04adec9c9 --- /dev/null +++ b/extensions/signal/src/monitor/mentions.ts @@ -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; +} diff --git a/extensions/signal/src/probe.test.ts b/extensions/signal/src/probe.test.ts new file mode 100644 index 000000000..7250c1de7 --- /dev/null +++ b/extensions/signal/src/probe.test.ts @@ -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); + }); +}); diff --git a/extensions/signal/src/probe.ts b/extensions/signal/src/probe.ts new file mode 100644 index 000000000..bf200effd --- /dev/null +++ b/extensions/signal/src/probe.ts @@ -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 { + 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, + }; +} diff --git a/extensions/signal/src/reaction-level.ts b/extensions/signal/src/reaction-level.ts new file mode 100644 index 000000000..884bccec5 --- /dev/null +++ b/extensions/signal/src/reaction-level.ts @@ -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", + }); +} diff --git a/extensions/signal/src/rpc-context.ts b/extensions/signal/src/rpc-context.ts new file mode 100644 index 000000000..54c123cc6 --- /dev/null +++ b/extensions/signal/src/rpc-context.ts @@ -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, +) { + 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 }; +} diff --git a/extensions/signal/src/send-reactions.test.ts b/extensions/signal/src/send-reactions.test.ts new file mode 100644 index 000000000..47f0bbd88 --- /dev/null +++ b/extensions/signal/src/send-reactions.test.ts @@ -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(); + 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; + 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; + 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; + expect(params.recipients).toEqual(["+15551230000"]); + expect(params.targetAuthor).toBe("+15551230000"); + expect(params.remove).toBe(true); + }); +}); diff --git a/extensions/signal/src/send-reactions.ts b/extensions/signal/src/send-reactions.ts new file mode 100644 index 000000000..a5000ca9e --- /dev/null +++ b/extensions/signal/src/send-reactions.ts @@ -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 { + 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 = { + 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 { + 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 { + 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", + }, + }); +} diff --git a/extensions/signal/src/send.ts b/extensions/signal/src/send.ts new file mode 100644 index 000000000..bb9536802 --- /dev/null +++ b/extensions/signal/src/send.ts @@ -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; + +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 { + 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" ? "" : ``; + 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 = { 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 { + const { baseUrl, account } = resolveSignalRpcContext(opts); + const targetParams = buildTargetParams(parseTarget(to), { + recipient: true, + group: true, + }); + if (!targetParams) { + return false; + } + const params: Record = { ...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 { + 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 = { + ...targetParams, + targetTimestamp, + type: opts.type ?? "read", + }; + if (account) { + params.account = account; + } + await signalRpcRequest("sendReceipt", params, { + baseUrl, + timeoutMs: opts.timeoutMs, + }); + return true; +} diff --git a/extensions/signal/src/sse-reconnect.ts b/extensions/signal/src/sse-reconnect.ts new file mode 100644 index 000000000..240ec7a4b --- /dev/null +++ b/extensions/signal/src/sse-reconnect.ts @@ -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; +}; + +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; + } + } + } +} diff --git a/src/signal/accounts.ts b/src/signal/accounts.ts index ed5732b91..8b06971c6 100644 --- a/src/signal/accounts.ts +++ b/src/signal/accounts.ts @@ -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"; diff --git a/src/signal/client.test.ts b/src/signal/client.test.ts index 109ec5f94..ec5c12b80 100644 --- a/src/signal/client.test.ts +++ b/src/signal/client.test.ts @@ -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"; diff --git a/src/signal/client.ts b/src/signal/client.ts index 198e1ad45..9ec64219f 100644 --- a/src/signal/client.ts +++ b/src/signal/client.ts @@ -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 = { - 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(text: string, status: number): SignalRpcResponse { - 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; - 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( - method: string, - params: Record | undefined, - opts: SignalRpcOptions, -): Promise { - 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(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 { - 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"; diff --git a/src/signal/daemon.ts b/src/signal/daemon.ts index 93f116d46..f589b1f3d 100644 --- a/src/signal/daemon.ts +++ b/src/signal/daemon.ts @@ -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; - 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((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"; diff --git a/src/signal/format.chunking.test.ts b/src/signal/format.chunking.test.ts index 5c17ef581..47cbc03d1 100644 --- a/src/signal/format.chunking.test.ts +++ b/src/signal/format.chunking.test.ts @@ -1,388 +1,2 @@ -import { describe, expect, it } from "vitest"; -import { markdownToSignalTextChunks } from "./format.js"; - -function expectChunkStyleRangesInBounds(chunks: ReturnType) { - 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"; diff --git a/src/signal/format.links.test.ts b/src/signal/format.links.test.ts index c6ec112a7..dcdf819b9 100644 --- a/src/signal/format.links.test.ts +++ b/src/signal/format.links.test.ts @@ -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"; diff --git a/src/signal/format.test.ts b/src/signal/format.test.ts index e22a6607f..0ca68819d 100644 --- a/src/signal/format.test.ts +++ b/src/signal/format.test.ts @@ -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"; diff --git a/src/signal/format.ts b/src/signal/format.ts index 8f35a34f2..bf602517f 100644 --- a/src/signal/format.ts +++ b/src/signal/format.ts @@ -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"; diff --git a/src/signal/format.visual.test.ts b/src/signal/format.visual.test.ts index 78f913b79..c75e26c66 100644 --- a/src/signal/format.visual.test.ts +++ b/src/signal/format.visual.test.ts @@ -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"; diff --git a/src/signal/identity.test.ts b/src/signal/identity.test.ts index a09f81910..6f04d6b01 100644 --- a/src/signal/identity.test.ts +++ b/src/signal/identity.test.ts @@ -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"; diff --git a/src/signal/identity.ts b/src/signal/identity.ts index 965a9c88f..d73d2bf4a 100644 --- a/src/signal/identity.ts +++ b/src/signal/identity.ts @@ -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"; diff --git a/src/signal/index.ts b/src/signal/index.ts index 29f241149..3741162bb 100644 --- a/src/signal/index.ts +++ b/src/signal/index.ts @@ -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"; diff --git a/src/signal/monitor.test.ts b/src/signal/monitor.test.ts index a15956ce1..4ac86270e 100644 --- a/src/signal/monitor.test.ts +++ b/src/signal/monitor.test.ts @@ -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"; diff --git a/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts b/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts index 72572110e..b7ba05e2d 100644 --- a/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts +++ b/src/signal/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts @@ -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[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; - const baseSignal = (baseChannels.signal ?? {}) as Record; - 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"; diff --git a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index a06d17d61..2a217607f 100644 --- a/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/signal/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -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[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 = {}) { - setSignalToolResultTestConfig(createSignalConfig(overrides)); -} - -function createSignalConfig(overrides: Record = {}): Record { - const base = config as OpenClawConfig; - const channels = (base.channels ?? {}) as Record; - const signal = (channels.signal ?? {}) as Record; - 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; -}) { - 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 = {}) { - return { - sourceNumber: "+15550001111", - sourceName: "Ada", - timestamp: 1, - ...overrides, - }; -} - -async function receiveSingleEnvelope( - envelope: Record, - opts?: Partial, -) { - 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 = {}) { - 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((_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((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, - 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, - 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, - 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"; diff --git a/src/signal/monitor.tool-result.test-harness.ts b/src/signal/monitor.tool-result.test-harness.ts index f9248cc27..f01ee09bf 100644 --- a/src/signal/monitor.tool-result.test-harness.ts +++ b/src/signal/monitor.tool-result.test-harness.ts @@ -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 = {}; - -export function setSignalToolResultTestConfig(next: Record) { - config = next; -} - -export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -export function createMockSignalDaemonHandle( - overrides: { - stop?: MockFn; - exited?: Promise; - isExited?: () => boolean; - } = {}, -): SignalDaemonHandle { - const stop = overrides.stop ?? (vi.fn() as unknown as MockFn); - const exited = overrides.exited ?? new Promise(() => {}); - 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(); - 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(); - 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(); - 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"; diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 13812593c..dfb701661 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -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; - groupAllowFrom?: Array; - mediaMaxMb?: number; - reconnectPolicy?: Partial; -}; - -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[] { - 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 | 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 { - 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 = { - 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 { - 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(); - 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"; diff --git a/src/signal/monitor/access-policy.ts b/src/signal/monitor/access-policy.ts index e836868ec..f1dabdeaa 100644 --- a/src/signal/monitor/access-policy.ts +++ b/src/signal/monitor/access-policy.ts @@ -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; - log: (message: string) => void; -}): Promise { - 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"; diff --git a/src/signal/monitor/event-handler.inbound-contract.test.ts b/src/signal/monitor/event-handler.inbound-contract.test.ts index 88be22ea5..a2def3f7c 100644 --- a/src/signal/monitor/event-handler.inbound-contract.test.ts +++ b/src/signal/monitor/event-handler.inbound-contract.test.ts @@ -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 }; - }) => { - 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(); - 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"; diff --git a/src/signal/monitor/event-handler.mention-gating.test.ts b/src/signal/monitor/event-handler.mention-gating.test.ts index 38dedf5a8..788c97676 100644 --- a/src/signal/monitor/event-handler.mention-gating.test.ts +++ b/src/signal/monitor/event-handler.mention-gating.test.ts @@ -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 & { - 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(); - 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["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" }] }, - "", - ); - }); - - 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(""); - }); - - 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"; diff --git a/src/signal/monitor/event-handler.test-harness.ts b/src/signal/monitor/event-handler.test-harness.ts index 1c81dd081..d5c5959ba 100644 --- a/src/signal/monitor/event-handler.test-harness.ts +++ b/src/signal/monitor/event-handler.test-harness.ts @@ -1,49 +1,2 @@ -import type { SignalEventHandlerDeps, SignalReactionMessage } from "./event-handler.types.js"; - -export function createBaseSignalEventHandlerDeps( - overrides: Partial = {}, -): 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 = {}) { - 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"; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index c67e680b7..3d3c88d57 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -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 { - const kindCounts = new Map(); - 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({ - 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 ""; - } - 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 ? `` : ""; - })(); - 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 = ``; - } else if (attachments.length) { - placeholder = ""; - } - } - - 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"; diff --git a/src/signal/monitor/event-handler.types.ts b/src/signal/monitor/event-handler.types.ts index a7f3c6b1d..7186c5752 100644 --- a/src/signal/monitor/event-handler.types.ts +++ b/src/signal/monitor/event-handler.types.ts @@ -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; - mentions?: Array | 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; - 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; - 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"; diff --git a/src/signal/monitor/mentions.ts b/src/signal/monitor/mentions.ts index 04adec9c9..c1fd0ad99 100644 --- a/src/signal/monitor/mentions.ts +++ b/src/signal/monitor/mentions.ts @@ -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"; diff --git a/src/signal/probe.test.ts b/src/signal/probe.test.ts index 7250c1de7..a2cd90712 100644 --- a/src/signal/probe.test.ts +++ b/src/signal/probe.test.ts @@ -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"; diff --git a/src/signal/probe.ts b/src/signal/probe.ts index 924f99701..2ef2c35bd 100644 --- a/src/signal/probe.ts +++ b/src/signal/probe.ts @@ -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 { - 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"; diff --git a/src/signal/reaction-level.ts b/src/signal/reaction-level.ts index f3bd2ad74..676f9a838 100644 --- a/src/signal/reaction-level.ts +++ b/src/signal/reaction-level.ts @@ -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"; diff --git a/src/signal/rpc-context.ts b/src/signal/rpc-context.ts index f46ec3b12..c1685ff90 100644 --- a/src/signal/rpc-context.ts +++ b/src/signal/rpc-context.ts @@ -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, -) { - 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"; diff --git a/src/signal/send-reactions.test.ts b/src/signal/send-reactions.test.ts index 84d0dc53f..b98ddc984 100644 --- a/src/signal/send-reactions.test.ts +++ b/src/signal/send-reactions.test.ts @@ -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(); - 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; - 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; - 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; - 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"; diff --git a/src/signal/send-reactions.ts b/src/signal/send-reactions.ts index dba41bb8b..5bbd70a54 100644 --- a/src/signal/send-reactions.ts +++ b/src/signal/send-reactions.ts @@ -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 { - 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 = { - 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 { - 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 { - 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"; diff --git a/src/signal/send.ts b/src/signal/send.ts index 9dc4ef979..c6388fcd5 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -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; - -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 { - 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" ? "" : ``; - 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 = { 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 { - const { baseUrl, account } = resolveSignalRpcContext(opts); - const targetParams = buildTargetParams(parseTarget(to), { - recipient: true, - group: true, - }); - if (!targetParams) { - return false; - } - const params: Record = { ...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 { - 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 = { - ...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"; diff --git a/src/signal/sse-reconnect.ts b/src/signal/sse-reconnect.ts index f119388f3..7a49fc2db 100644 --- a/src/signal/sse-reconnect.ts +++ b/src/signal/sse-reconnect.ts @@ -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; -}; - -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"; diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index 7e2b76d74..a47562a32 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -7,7 +7,7 @@ "noEmit": false, "noEmitOnError": true, "outDir": "dist/plugin-sdk", - "rootDir": "src", + "rootDir": ".", "tsBuildInfoFile": "dist/plugin-sdk/.tsbuildinfo" }, "include": [