重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,15 @@
export { signalPlugin } from "./src/channel.js";
export { signalSetupPlugin } from "./src/channel.setup.js";
export * from "./src/accounts.js";
export * from "./src/format.js";
export * from "./src/identity.js";
export * from "./src/install-signal-cli.js";
export * from "./src/message-actions.js";
export * from "./src/monitor.js";
export * from "./src/normalize.js";
export * from "./src/outbound-session.js";
export * from "./src/probe.js";
export * from "./src/reaction-level.js";
export * from "./src/send-reactions.js";
export * from "./src/send.js";
export { normalizeSignalAccountInput } from "./src/setup-core.js";

View file

@ -0,0 +1 @@
export { SignalChannelConfigSchema } from "./src/config-schema.js";

View file

@ -0,0 +1,16 @@
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "signal",
name: "Signal",
description: "Signal channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "signalPlugin",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setSignalRuntime",
},
});

View file

@ -0,0 +1,4 @@
export {
buildChannelConfigSchema,
SignalConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";

View file

@ -0,0 +1,3 @@
export * from "./src/install-signal-cli.js";
export * from "./src/normalize.js";
export { isSignalSenderAllowed, type SignalSender } from "./src/identity.js";

View file

@ -0,0 +1,16 @@
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "signal",
name: "Signal",
description: "Signal channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "signalPlugin",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setSignalRuntime",
},
});

View file

@ -0,0 +1,9 @@
{
"id": "signal",
"channels": ["signal"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,27 @@
{
"name": "@openclaw/signal",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "signal",
"label": "Signal",
"selectionLabel": "Signal (signal-cli)",
"detailLabel": "Signal REST",
"docsPath": "/channels/signal",
"docsLabel": "signal",
"blurb": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").",
"systemImage": "antenna.radiowaves.left.and.right",
"markdownCapable": true
}
}
}

View file

@ -0,0 +1,6 @@
export {
removeReactionSignal,
sendReactionSignal,
type SignalReactionOpts,
type SignalReactionResult,
} from "./src/send-reactions.js";

View file

@ -0,0 +1,2 @@
export * from "./src/runtime-api.js";
export { setSignalRuntime } from "./src/runtime.js";

View file

@ -0,0 +1,4 @@
// Signal does not expose secret-contract surfaces.
export const secretTargetRegistryEntries: readonly [] = [];
export function collectRuntimeConfigAssignments(): void {}

View file

@ -0,0 +1,9 @@
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "signalSetupPlugin",
},
});

View file

@ -0,0 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export type SignalAccountConfig = Omit<
Exclude<NonNullable<OpenClawConfig["channels"]>["signal"], undefined>,
"accounts"
>;

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { resolveSignalAccount } from "./accounts.js";
describe("resolveSignalAccount", () => {
it("uses configured defaultAccount when accountId is omitted", () => {
const resolved = resolveSignalAccount({
cfg: {
channels: {
signal: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
account: "+15555550123",
httpUrl: "http://127.0.0.1:9999",
},
},
},
},
} as never,
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.baseUrl).toBe("http://127.0.0.1:9999");
expect(resolved.config.account).toBe("+15555550123");
expect(resolved.configured).toBe(true);
});
});

View file

@ -0,0 +1,69 @@
import {
createAccountListHelpers,
normalizeAccountId,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { SignalAccountConfig } from "./account-types.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 mergeSignalAccountConfig(cfg: OpenClawConfig, accountId: string): SignalAccountConfig {
return resolveMergedAccountConfig<SignalAccountConfig>({
channelConfig: cfg.channels?.signal as SignalAccountConfig | undefined,
accounts: cfg.channels?.signal?.accounts as
| Record<string, Partial<SignalAccountConfig>>
| undefined,
accountId,
});
}
export function resolveSignalAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedSignalAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSignalAccountId(params.cfg),
);
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 = normalizeOptionalString(merged.httpHost) ?? "127.0.0.1";
const port = merged.httpPort ?? 8080;
const baseUrl = normalizeOptionalString(merged.httpUrl) ?? `http://${host}:${port}`;
const configured = Boolean(
normalizeOptionalString(merged.account) ||
normalizeOptionalString(merged.httpUrl) ||
normalizeOptionalString(merged.cliPath) ||
normalizeOptionalString(merged.httpHost) ||
typeof merged.httpPort === "number" ||
typeof merged.autoStart === "boolean",
);
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
baseUrl,
configured,
config: merged,
};
}
export function listEnabledSignalAccounts(cfg: OpenClawConfig): ResolvedSignalAccount[] {
return listSignalAccountIds(cfg)
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { signalApprovalAuth } from "./approval-auth.js";
describe("signalApprovalAuth", () => {
it("authorizes phone and uuid approvers with stable sender ids", () => {
const cfg = {
channels: {
signal: {
allowFrom: ["uuid:ABCDEF12-3456-7890-ABCD-EF1234567890", "+1 (555) 123-0000"],
},
},
};
expect(
signalApprovalAuth.authorizeActorAction({
cfg,
senderId: "uuid:abcdef12-3456-7890-abcd-ef1234567890",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
expect(
signalApprovalAuth.authorizeActorAction({
cfg,
senderId: "+15551230000",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
});

View file

@ -0,0 +1,33 @@
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { resolveSignalAccount } from "./accounts.js";
import { normalizeSignalMessagingTarget } from "./normalize.js";
import { looksLikeUuid } from "./uuid.js";
function normalizeSignalApproverId(value: string | number): string | undefined {
const normalized = normalizeSignalMessagingTarget(String(value));
if (!normalized || normalized.startsWith("group:") || normalized.startsWith("username:")) {
return undefined;
}
if (looksLikeUuid(normalized)) {
return `uuid:${normalized}`;
}
const e164 = normalizeE164(normalized);
return e164.length > 1 ? e164 : undefined;
}
export const signalApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Signal",
resolveApprovers: ({ cfg, accountId }) => {
const account = resolveSignalAccount({ cfg, accountId }).config;
return resolveApprovalApprovers({
allowFrom: account.allowFrom,
defaultTo: account.defaultTo,
normalizeApprover: normalizeSignalApproverId,
});
},
normalizeSenderId: (value) => normalizeSignalApproverId(value),
});

View file

@ -0,0 +1,5 @@
import { signalSetupWizard as signalSetupWizardImpl } from "./setup-surface.js";
type SignalSetupWizard = typeof import("./setup-surface.js").signalSetupWizard;
export const signalSetupWizard: SignalSetupWizard = { ...signalSetupWizardImpl };

View file

@ -0,0 +1,11 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { type ResolvedSignalAccount } from "./accounts.js";
import { signalSetupAdapter } from "./setup-core.js";
import { createSignalPluginBase, signalSetupWizard } from "./shared.js";
export const signalSetupPlugin: ChannelPlugin<ResolvedSignalAccount> = {
...createSignalPluginBase({
setupWizard: signalSetupWizard,
setup: signalSetupAdapter,
}),
};

View file

@ -0,0 +1,408 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
attachChannelToResult,
attachChannelToResults,
} from "openclaw/plugin-sdk/channel-send-result";
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
import { chunkText, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
import {
buildBaseChannelStatusSummary,
collectStatusIssuesFromLastError,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
import { signalApprovalAuth } from "./approval-auth.js";
import { markdownToSignalTextChunks } from "./format.js";
import { signalMessageActions } from "./message-actions.js";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
import { resolveSignalOutboundTarget } from "./outbound-session.js";
import { resolveSignalReactionLevel } from "./reaction-level.js";
import { signalSetupAdapter } from "./setup-core.js";
import {
createSignalPluginBase,
signalConfigAdapter,
signalSecurityAdapter,
signalSetupWizard,
} from "./shared.js";
type SignalSendFn = typeof import("./send.runtime.js").sendMessageSignal;
type SignalProbe = import("./probe.js").SignalProbe;
let signalMonitorModulePromise: Promise<typeof import("./monitor.js")> | null = null;
let signalProbeModulePromise: Promise<typeof import("./probe.js")> | null = null;
let signalSendRuntimePromise: Promise<typeof import("./send.runtime.js")> | null = null;
async function loadSignalMonitorModule() {
signalMonitorModulePromise ??= import("./monitor.js");
return await signalMonitorModulePromise;
}
async function loadSignalProbeModule() {
signalProbeModulePromise ??= import("./probe.js");
return await signalProbeModulePromise;
}
async function loadSignalSendRuntime() {
signalSendRuntimePromise ??= import("./send.runtime.js");
return await signalSendRuntimePromise;
}
async function resolveSignalSendContext(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
accountId?: string;
deps?: { [channelId: string]: unknown };
}) {
const send =
resolveOutboundSendDep<SignalSendFn>(params.deps, "signal") ??
(await loadSignalSendRuntime()).sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb,
accountId: params.accountId,
});
return { send, maxBytes };
}
async function sendSignalOutbound(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
to: string;
text: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string;
deps?: { [channelId: string]: unknown };
}) {
const { send, maxBytes } = await resolveSignalSendContext(params);
return await send(params.to, params.text, {
cfg: params.cfg,
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}),
maxBytes,
accountId: params.accountId ?? undefined,
});
}
function inferSignalTargetChatType(rawTo: string) {
let to = rawTo.trim();
if (!to) {
return undefined;
}
if (/^signal:/i.test(to)) {
to = to.replace(/^signal:/i, "").trim();
}
if (!to) {
return undefined;
}
const lower = normalizeLowercaseStringOrEmpty(to);
if (lower.startsWith("group:")) {
return "group" as const;
}
if (lower.startsWith("username:") || lower.startsWith("u:")) {
return "direct" as const;
}
return "direct" as const;
}
function parseSignalExplicitTarget(raw: string) {
const normalized = normalizeSignalMessagingTarget(raw);
if (!normalized) {
return null;
}
return {
to: normalized,
chatType: inferSignalTargetChatType(normalized),
};
}
function buildSignalBaseSessionKey(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
agentId: string;
accountId?: string | null;
peer: RoutePeer;
}) {
return buildOutboundBaseSessionKey({ ...params, channel: "signal" });
}
function resolveSignalOutboundSessionRoute(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
agentId: string;
accountId?: string | null;
target: string;
}) {
const resolved = resolveSignalOutboundTarget(params.target);
if (!resolved) {
return null;
}
const baseSessionKey = buildSignalBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
accountId: params.accountId,
peer: resolved.peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
...resolved,
};
}
async function sendFormattedSignalText(ctx: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
to: string;
text: string;
accountId?: string | null;
deps?: { [channelId: string]: unknown };
abortSignal?: AbortSignal;
}) {
const { send, maxBytes } = await resolveSignalSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
});
const limit = resolveTextChunkLimit(ctx.cfg, "signal", ctx.accountId ?? undefined, {
fallbackLimit: 4000,
});
const tableMode = resolveMarkdownTableMode({
cfg: ctx.cfg,
channel: "signal",
accountId: ctx.accountId ?? undefined,
});
let chunks =
limit === undefined
? markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, { tableMode })
: markdownToSignalTextChunks(ctx.text, limit, { tableMode });
if (chunks.length === 0 && ctx.text) {
chunks = [{ text: ctx.text, styles: [] }];
}
const results = [];
for (const chunk of chunks) {
ctx.abortSignal?.throwIfAborted();
const result = await send(ctx.to, chunk.text, {
cfg: ctx.cfg,
maxBytes,
accountId: ctx.accountId ?? undefined,
textMode: "plain",
textStyles: chunk.styles,
});
results.push(result);
}
return attachChannelToResults("signal", results);
}
async function sendFormattedSignalMedia(ctx: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
to: string;
text: string;
mediaUrl: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string | null;
deps?: { [channelId: string]: unknown };
abortSignal?: AbortSignal;
}) {
ctx.abortSignal?.throwIfAborted();
const { send, maxBytes } = await resolveSignalSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
});
const tableMode = resolveMarkdownTableMode({
cfg: ctx.cfg,
channel: "signal",
accountId: ctx.accountId ?? undefined,
});
const formatted = markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, {
tableMode,
})[0] ?? {
text: ctx.text,
styles: [],
};
const result = await send(ctx.to, formatted.text, {
cfg: ctx.cfg,
mediaUrl: ctx.mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
maxBytes,
accountId: ctx.accountId ?? undefined,
textMode: "plain",
textStyles: formatted.styles,
});
return attachChannelToResult("signal", result);
}
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
createChatChannelPlugin({
base: {
...createSignalPluginBase({
setupWizard: signalSetupWizard,
setup: signalSetupAdapter,
}),
actions: signalMessageActions,
approvalCapability: signalApprovalAuth,
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "signal",
resolveAccount: resolveSignalAccount,
normalize: ({ cfg, accountId, values }) =>
signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupAllowFrom: (account) => account.config.groupAllowFrom,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveGroupPolicy: (account) => account.config.groupPolicy,
}),
agentPrompt: {
reactionGuidance: ({ cfg, accountId }) => {
const level = resolveSignalReactionLevel({
cfg,
accountId: accountId ?? undefined,
}).agentReactionGuidance;
return level ? { level, channelLabel: "Signal" } : undefined;
},
},
messaging: {
normalizeTarget: normalizeSignalMessagingTarget,
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveSignalOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeSignalTargetId,
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
},
},
status: createComputedAccountStatusAdapter<ResolvedSignalAccount, SignalProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("signal", accounts),
buildChannelSummary: ({ snapshot }) =>
buildBaseChannelStatusSummary(snapshot, {
baseUrl: snapshot.baseUrl ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
const baseUrl = account.baseUrl;
const { probeSignal } = await loadSignalProbeModule();
return await probeSignal(baseUrl, timeoutMs);
},
formatCapabilitiesProbe: ({ probe }) =>
probe?.version ? [{ text: `Signal daemon: ${probe.version}` }] : [],
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
},
}),
}),
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.baseUrl,
});
ctx.log?.info(`[${account.accountId}] starting provider (${account.baseUrl})`);
const { monitorSignalProvider } = await loadSignalMonitorModule();
return await monitorSignalProvider({
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
});
},
},
},
pairing: {
text: {
idLabel: "signalNumber",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^signal:/i),
notify: async ({ id, message }) => {
await (await loadSignalSendRuntime()).sendMessageSignal(id, message);
},
},
},
security: signalSecurityAdapter,
outbound: {
base: {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) =>
await sendFormattedSignalText({
cfg,
to,
text,
accountId,
deps,
abortSignal,
}),
sendFormattedMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
abortSignal,
}) =>
await sendFormattedSignalMedia({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
abortSignal,
}),
},
attachedResults: {
channel: "signal",
sendText: async ({ cfg, to, text, accountId, deps }) =>
await sendSignalOutbound({
cfg,
to,
text,
accountId: accountId ?? undefined,
deps,
}),
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
}) =>
await sendSignalOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId: accountId ?? undefined,
deps,
}),
},
},
});

View file

@ -0,0 +1,77 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const fetchWithTimeoutMock = vi.fn();
const resolveFetchMock = vi.fn();
vi.mock("openclaw/plugin-sdk/fetch-runtime", () => ({
resolveFetch: (...args: unknown[]) => resolveFetchMock(...args),
}));
vi.mock("openclaw/plugin-sdk/core", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/core")>(
"openclaw/plugin-sdk/core",
);
return {
...actual,
generateSecureUuid: () => "test-id",
};
});
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args),
}));
let signalRpcRequest: typeof import("./client.js").signalRpcRequest;
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", () => {
beforeAll(async () => {
({ signalRpcRequest } = await import("./client.js"));
});
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)");
});
});

View file

@ -0,0 +1,216 @@
import { generateSecureUuid } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
export type SignalRpcOptions = {
baseUrl: string;
timeoutMs?: number;
};
export type SignalRpcError = {
code?: number;
message?: string;
data?: unknown;
};
export type SignalRpcResponse<T> = {
jsonrpc?: string;
result?: T;
error?: SignalRpcError;
id?: string | number | null;
};
export type SignalSseEvent = {
event?: string;
data?: string;
id?: string;
};
const DEFAULT_TIMEOUT_MS = 10_000;
function normalizeBaseUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) {
throw new Error("Signal base URL is required");
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed.replace(/\/+$/, "");
}
return `http://${trimmed}`.replace(/\/+$/, "");
}
function getRequiredFetch(): typeof fetch {
const fetchImpl = resolveFetch();
if (!fetchImpl) {
throw new Error("fetch is not available");
}
return fetchImpl;
}
function parseSignalRpcResponse<T>(text: string, status: number): SignalRpcResponse<T> {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
throw new Error(`Signal RPC returned malformed JSON (status ${status})`, { cause: err });
}
if (!parsed || typeof parsed !== "object") {
throw new Error(`Signal RPC returned invalid response envelope (status ${status})`);
}
const rpc = parsed as SignalRpcResponse<T>;
const hasResult = Object.hasOwn(rpc, "result");
if (!rpc.error && !hasResult) {
throw new Error(`Signal RPC returned invalid response envelope (status ${status})`);
}
return rpc;
}
export async function signalRpcRequest<T = unknown>(
method: string,
params: Record<string, unknown> | undefined,
opts: SignalRpcOptions,
): Promise<T> {
const baseUrl = normalizeBaseUrl(opts.baseUrl);
const id = generateSecureUuid();
const body = JSON.stringify({
jsonrpc: "2.0",
method,
params,
id,
});
const res = await fetchWithTimeout(
`${baseUrl}/api/v1/rpc`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
},
opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
getRequiredFetch(),
);
if (res.status === 201) {
return undefined as T;
}
const text = await res.text();
if (!text) {
throw new Error(`Signal RPC empty response (status ${res.status})`);
}
const parsed = parseSignalRpcResponse<T>(text, res.status);
if (parsed.error) {
const code = parsed.error.code ?? "unknown";
const msg = parsed.error.message ?? "Signal RPC error";
throw new Error(`Signal RPC ${code}: ${msg}`);
}
return parsed.result as T;
}
export async function signalCheck(
baseUrl: string,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
const normalized = normalizeBaseUrl(baseUrl);
try {
const res = await fetchWithTimeout(
`${normalized}/api/v1/check`,
{ method: "GET" },
timeoutMs,
getRequiredFetch(),
);
if (!res.ok) {
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
}
return { ok: true, status: res.status, error: null };
} catch (err) {
return {
ok: false,
status: null,
error: formatErrorMessage(err),
};
}
}
export async function streamSignalEvents(params: {
baseUrl: string;
account?: string;
abortSignal?: AbortSignal;
onEvent: (event: SignalSseEvent) => void;
}): Promise<void> {
const baseUrl = normalizeBaseUrl(params.baseUrl);
const url = new URL(`${baseUrl}/api/v1/events`);
if (params.account) {
url.searchParams.set("account", params.account);
}
const fetchImpl = resolveFetch();
if (!fetchImpl) {
throw new Error("fetch is not available");
}
const res = await fetchImpl(url, {
method: "GET",
headers: { Accept: "text/event-stream" },
signal: params.abortSignal,
});
if (!res.ok || !res.body) {
throw new Error(`Signal SSE failed (${res.status} ${res.statusText || "error"})`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let currentEvent: SignalSseEvent = {};
const flushEvent = () => {
if (!currentEvent.data && !currentEvent.event && !currentEvent.id) {
return;
}
params.onEvent({
event: currentEvent.event,
data: currentEvent.data,
id: currentEvent.id,
});
currentEvent = {};
};
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let lineEnd = buffer.indexOf("\n");
while (lineEnd !== -1) {
let line = buffer.slice(0, lineEnd);
buffer = buffer.slice(lineEnd + 1);
if (line.endsWith("\r")) {
line = line.slice(0, -1);
}
if (line === "") {
flushEvent();
lineEnd = buffer.indexOf("\n");
continue;
}
if (line.startsWith(":")) {
lineEnd = buffer.indexOf("\n");
continue;
}
const [rawField, ...rest] = line.split(":");
const field = rawField.trim();
const rawValue = rest.join(":");
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
if (field === "event") {
currentEvent.event = value;
} else if (field === "data") {
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${value}` : value;
} else if (field === "id") {
currentEvent.id = value;
}
lineEnd = buffer.indexOf("\n");
}
}
flushEvent();
}

View file

@ -0,0 +1,6 @@
import { buildChannelConfigSchema, SignalConfigSchema } from "../config-api.js";
import { signalChannelConfigUiHints } from "./config-ui-hints.js";
export const SignalChannelConfigSchema = buildChannelConfigSchema(SignalConfigSchema, {
uiHints: signalChannelConfigUiHints,
});

View file

@ -0,0 +1,20 @@
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core";
export const signalChannelConfigUiHints = {
"": {
label: "Signal",
help: "Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups.",
},
dmPolicy: {
label: "Signal DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
},
configWrites: {
label: "Signal Config Writes",
help: "Allow Signal to write config in response to channel events/commands (default: true).",
},
account: {
label: "Signal Account",
help: "Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.",
},
} satisfies Record<string, ChannelConfigUiHint>;

View file

@ -0,0 +1,377 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it, vi } from "vitest";
import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js";
import { signalPlugin } from "./channel.js";
import * as clientModule from "./client.js";
import { classifySignalCliLogLine } from "./daemon.js";
import {
looksLikeUuid,
resolveSignalPeerId,
resolveSignalRecipient,
resolveSignalSender,
} from "./identity.js";
import { probeSignal } from "./probe.js";
import { clearSignalRuntime } from "./runtime.js";
import {
normalizeSignalAccountInput,
parseSignalAllowFromEntries,
signalDmPolicy,
} from "./setup-core.js";
const getSignalSetupStatus = createPluginSetupWizardStatus(signalPlugin);
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");
});
});
describe("probeSignal", () => {
it("falls back to the direct probe helper when runtime is not initialized", async () => {
clearSignalRuntime();
vi.spyOn(clientModule, "signalCheck")
.mockResolvedValueOnce({
ok: true,
status: 200,
error: null,
})
.mockResolvedValueOnce({
ok: true,
status: 200,
error: null,
});
vi.spyOn(clientModule, "signalRpcRequest")
.mockResolvedValueOnce({ version: "0.13.22" })
.mockResolvedValueOnce({ version: "0.13.22" });
const params = {
cfg: {} as never,
account: {
accountId: "default",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:8080",
} as never,
timeoutMs: 1000,
};
const expected = await probeSignal("http://127.0.0.1:8080", 1000);
await expect(signalPlugin.status!.probeAccount!(params)).resolves.toEqual(
expect.objectContaining({
ok: expected.ok,
status: expected.status,
error: expected.error,
version: expected.version,
}),
);
});
it("extracts version from {version} result", async () => {
vi.spyOn(clientModule, "signalCheck").mockResolvedValueOnce({
ok: true,
status: 200,
error: null,
});
vi.spyOn(clientModule, "signalRpcRequest").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 () => {
vi.spyOn(clientModule, "signalCheck").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);
});
it("setup status lines use the selected account cliPath", async () => {
const status = await getSignalSetupStatus({
cfg: {
channels: {
signal: {
cliPath: "/tmp/root-signal-cli",
accounts: {
work: {
cliPath: "/tmp/work-signal-cli",
},
},
},
},
} as never,
accountOverrides: { signal: "work" },
});
expect(status.statusLines).toContain("signal-cli: missing (/tmp/work-signal-cli)");
});
it("setup status uses configured defaultAccount for omitted cliPath lookup", async () => {
const status = await getSignalSetupStatus({
cfg: {
channels: {
signal: {
cliPath: "/tmp/root-signal-cli",
defaultAccount: "work",
accounts: {
work: {
cliPath: "/tmp/work-signal-cli",
},
},
},
},
} as never,
accountOverrides: {},
});
expect(status.statusLines).toContain("signal-cli: missing (/tmp/work-signal-cli)");
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const status = await getSignalSetupStatus({
cfg: {
channels: {
signal: {
defaultAccount: "work",
cliPath: "/tmp/root-signal-cli",
accounts: {
alerts: {
cliPath: "/tmp/alerts-signal-cli",
},
work: {
cliPath: "",
account: "",
httpHost: "",
httpUrl: "",
},
},
},
},
} as OpenClawConfig,
accountOverrides: {},
});
expect(status.configured).toBe(true);
});
});
describe("signal outbound", () => {
it("chunks outbound text without requiring Signal runtime initialization", () => {
clearSignalRuntime();
const chunker = signalPlugin.outbound?.chunker;
if (!chunker) {
throw new Error("signal outbound.chunker unavailable");
}
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
});
});
describe("classifySignalCliLogLine", () => {
it("treats INFO/DEBUG as log", () => {
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);
});
});
describe("signal setup parsing", () => {
it("accepts already normalized numbers", () => {
expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123");
});
it("normalizes valid E.164 numbers", () => {
expect(normalizeSignalAccountInput(" +1 (555) 555-0123 ")).toBe("+15555550123");
});
it("rejects empty input", () => {
expect(normalizeSignalAccountInput(" ")).toBeNull();
});
it("rejects invalid values", () => {
expect(normalizeSignalAccountInput("abc")).toBeNull();
expect(normalizeSignalAccountInput("++--")).toBeNull();
});
it("rejects inputs with stray + characters", () => {
expect(normalizeSignalAccountInput("++12345")).toBeNull();
expect(normalizeSignalAccountInput("+1+2345")).toBeNull();
});
it("rejects numbers that are too short or too long", () => {
expect(normalizeSignalAccountInput("+1234")).toBeNull();
expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull();
});
it("parses e164, uuid and wildcard entries", () => {
expect(
parseSignalAllowFromEntries("+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000, *"),
).toEqual({
entries: ["+15555550123", "uuid:123e4567-e89b-12d3-a456-426614174000", "*"],
});
});
it("normalizes bare uuid values", () => {
expect(parseSignalAllowFromEntries("123e4567-e89b-12d3-a456-426614174000")).toEqual({
entries: ["uuid:123e4567-e89b-12d3-a456-426614174000"],
});
});
it("returns validation errors for invalid entries", () => {
expect(parseSignalAllowFromEntries("uuid:")).toEqual({
entries: [],
error: "Invalid uuid entry",
});
expect(parseSignalAllowFromEntries("invalid")).toEqual({
entries: [],
error: "Invalid entry: invalid",
});
});
it("reads the named-account DM policy instead of the channel root", () => {
expect(
signalDmPolicy.getCurrent(
{
channels: {
signal: {
dmPolicy: "disabled",
accounts: {
work: {
account: "+15555550123",
dmPolicy: "allowlist",
},
},
},
},
},
"work",
),
).toBe("allowlist");
});
it("reports account-scoped config keys for named accounts", () => {
expect(signalDmPolicy.resolveConfigKeys?.({ channels: { signal: {} } }, "work")).toEqual({
policyKey: "channels.signal.accounts.work.dmPolicy",
allowFromKey: "channels.signal.accounts.work.allowFrom",
});
});
it("uses configured defaultAccount for omitted DM policy account context", () => {
const cfg: OpenClawConfig = {
channels: {
signal: {
defaultAccount: "work",
dmPolicy: "disabled",
allowFrom: ["+15555550123"],
accounts: {
work: {
account: "+15555550999",
dmPolicy: "allowlist",
},
},
},
},
};
expect(signalDmPolicy.getCurrent(cfg)).toBe("allowlist");
expect(signalDmPolicy.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.signal.accounts.work.dmPolicy",
allowFromKey: "channels.signal.accounts.work.allowFrom",
});
const next = signalDmPolicy.setPolicy(cfg, "open");
expect(next.channels?.signal?.dmPolicy).toBe("disabled");
expect(next.channels?.signal?.allowFrom).toEqual(["+15555550123"]);
expect(next.channels?.signal?.accounts?.work?.dmPolicy).toBe("open");
expect(next.channels?.signal?.accounts?.work?.allowFrom).toEqual(["+15555550123", "*"]);
});
it('writes open policy state to the named account and stores inherited allowFrom with "*"', () => {
const cfg: OpenClawConfig = {
channels: {
signal: {
allowFrom: ["+15555550123"],
accounts: {
work: {
account: "+15555550999",
},
},
},
},
};
const next = signalDmPolicy.setPolicy(cfg, "open", "work");
expect(next.channels?.signal?.dmPolicy).toBeUndefined();
expect(next.channels?.signal?.allowFrom).toEqual(["+15555550123"]);
expect(next.channels?.signal?.accounts?.work?.dmPolicy).toBe("open");
expect(next.channels?.signal?.accounts?.work?.allowFrom).toEqual(["+15555550123", "*"]);
});
});

View file

@ -0,0 +1,147 @@
import { spawn } from "node:child_process";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
export type SignalDaemonOpts = {
cliPath: string;
account?: string;
httpHost: string;
httpPort: number;
receiveMode?: "on-start" | "manual";
ignoreAttachments?: boolean;
ignoreStories?: boolean;
sendReadReceipts?: boolean;
runtime?: RuntimeEnv;
};
export type SignalDaemonHandle = {
pid?: number;
stop: () => void;
exited: Promise<SignalDaemonExitEvent>;
isExited: () => boolean;
};
export type SignalDaemonExitEvent = {
source: "process" | "spawn-error";
code: number | null;
signal: NodeJS.Signals | null;
};
export function formatSignalDaemonExit(exit: SignalDaemonExitEvent): string {
return `signal daemon exited (source=${exit.source} code=${exit.code ?? "null"} signal=${exit.signal ?? "null"})`;
}
export function classifySignalCliLogLine(line: string): "log" | "error" | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
// signal-cli commonly writes all logs to stderr; treat severity explicitly.
if (/\b(ERROR|WARN|WARNING)\b/.test(trimmed)) {
return "error";
}
// Some signal-cli failures are not tagged with WARN/ERROR but should still be surfaced loudly.
if (/\b(FAILED|SEVERE|EXCEPTION)\b/i.test(trimmed)) {
return "error";
}
return "log";
}
function bindSignalCliOutput(params: {
stream: NodeJS.ReadableStream | null | undefined;
log: (message: string) => void;
error: (message: string) => void;
}): void {
params.stream?.on("data", (data) => {
for (const line of data.toString().split(/\r?\n/)) {
const kind = classifySignalCliLogLine(line);
if (kind === "log") {
params.log(`signal-cli: ${line.trim()}`);
} else if (kind === "error") {
params.error(`signal-cli: ${line.trim()}`);
}
}
});
}
function buildDaemonArgs(opts: SignalDaemonOpts): string[] {
const args: string[] = [];
if (opts.account) {
args.push("-a", opts.account);
}
args.push("daemon");
args.push("--http", `${opts.httpHost}:${opts.httpPort}`);
args.push("--no-receive-stdout");
if (opts.receiveMode) {
args.push("--receive-mode", opts.receiveMode);
}
if (opts.ignoreAttachments) {
args.push("--ignore-attachments");
}
if (opts.ignoreStories) {
args.push("--ignore-stories");
}
if (opts.sendReadReceipts) {
args.push("--send-read-receipts");
}
return args;
}
export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
const args = buildDaemonArgs(opts);
const child = spawn(opts.cliPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});
const log = opts.runtime?.log ?? (() => {});
const error = opts.runtime?.error ?? (() => {});
let exited = false;
let settledExit = false;
let resolveExit!: (value: SignalDaemonExitEvent) => void;
const exitedPromise = new Promise<SignalDaemonExitEvent>((resolve) => {
resolveExit = resolve;
});
const settleExit = (value: SignalDaemonExitEvent) => {
if (settledExit) {
return;
}
settledExit = true;
exited = true;
resolveExit(value);
};
bindSignalCliOutput({ stream: child.stdout, log, error });
bindSignalCliOutput({ stream: child.stderr, log, error });
child.once("exit", (code, signal) => {
settleExit({
source: "process",
code: typeof code === "number" ? code : null,
signal: signal ?? null,
});
error(
formatSignalDaemonExit({ source: "process", code: code ?? null, signal: signal ?? null }),
);
});
child.once("close", (code, signal) => {
settleExit({
source: "process",
code: typeof code === "number" ? code : null,
signal: signal ?? null,
});
});
child.on("error", (err) => {
error(`signal-cli spawn error: ${String(err)}`);
settleExit({ source: "spawn-error", code: null, signal: null });
});
return {
pid: child.pid ?? undefined,
exited: exitedPromise,
isExited: () => exited,
stop: () => {
if (!child.killed && !exited) {
child.kill("SIGTERM");
}
},
};
}

View file

@ -0,0 +1,388 @@
import { describe, expect, it } from "vitest";
import { markdownToSignalTextChunks } from "./format.js";
function expectChunkStyleRangesInBounds(chunks: ReturnType<typeof markdownToSignalTextChunks>) {
for (const chunk of chunks) {
for (const style of chunk.styles) {
expect(style.start).toBeGreaterThanOrEqual(0);
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
expect(style.length).toBeGreaterThan(0);
}
}
}
describe("splitSignalFormattedText", () => {
// We test the internal chunking behavior via markdownToSignalTextChunks with
// pre-rendered SignalFormattedText. The helper is not exported, so we test
// it indirectly through integration tests and by constructing scenarios that
// exercise the splitting logic.
describe("style-aware splitting - basic text", () => {
it("text with no styles splits correctly at whitespace", () => {
// Create text that exceeds limit and must be split
const limit = 20;
const markdown = "hello world this is a test";
const chunks = markdownToSignalTextChunks(markdown, limit);
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(limit);
}
// Verify all text is preserved (joined chunks should contain all words)
const joinedText = chunks.map((c) => c.text).join(" ");
expect(joinedText).toContain("hello");
expect(joinedText).toContain("world");
expect(joinedText).toContain("test");
});
it("empty text returns empty array", () => {
// Empty input produces no chunks (not an empty chunk)
const chunks = markdownToSignalTextChunks("", 100);
expect(chunks).toEqual([]);
});
it("text under limit returns single chunk unchanged", () => {
const markdown = "short text";
const chunks = markdownToSignalTextChunks(markdown, 100);
expect(chunks).toHaveLength(1);
expect(chunks[0].text).toBe("short text");
});
});
describe("style-aware splitting - style preservation", () => {
it("style fully within first chunk stays in first chunk", () => {
// Create a message where bold text is in the first chunk
const limit = 30;
const markdown = "**bold** word more words here that exceed limit";
const chunks = markdownToSignalTextChunks(markdown, limit);
expect(chunks.length).toBeGreaterThan(1);
// First chunk should contain the bold style
const firstChunk = chunks[0];
expect(firstChunk.text).toContain("bold");
expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true);
// The bold style should start at position 0 in the first chunk
const boldStyle = firstChunk.styles.find((s) => s.style === "BOLD");
expect(boldStyle).toBeDefined();
expect(boldStyle!.start).toBe(0);
expect(boldStyle!.length).toBe(4); // "bold"
});
it("style fully within second chunk has offset adjusted to chunk-local position", () => {
// Create a message where the styled text is in the second chunk
const limit = 30;
const markdown = "some filler text here **bold** at the end";
const chunks = markdownToSignalTextChunks(markdown, limit);
expect(chunks.length).toBeGreaterThan(1);
// Find the chunk containing "bold"
const chunkWithBold = chunks.find((c) => c.text.includes("bold"));
expect(chunkWithBold).toBeDefined();
expect(chunkWithBold!.styles.some((s) => s.style === "BOLD")).toBe(true);
// The bold style should have chunk-local offset (not original text offset)
const boldStyle = chunkWithBold!.styles.find((s) => s.style === "BOLD");
expect(boldStyle).toBeDefined();
// The offset should be the position within this chunk, not the original text
const boldPos = chunkWithBold!.text.indexOf("bold");
expect(boldStyle!.start).toBe(boldPos);
expect(boldStyle!.length).toBe(4);
});
it("style spanning chunk boundary is split into two ranges", () => {
// Create text where a styled span crosses the chunk boundary
const limit = 15;
// "hello **bold text here** end" - the bold spans across chunk boundary
const markdown = "hello **boldtexthere** end";
const chunks = markdownToSignalTextChunks(markdown, limit);
expect(chunks.length).toBeGreaterThan(1);
// Both chunks should have BOLD styles if the span was split
const chunksWithBold = chunks.filter((c) => c.styles.some((s) => s.style === "BOLD"));
// At least one chunk should have the bold style
expect(chunksWithBold.length).toBeGreaterThanOrEqual(1);
// For each chunk with bold, verify the style range is valid for that chunk
for (const chunk of chunksWithBold) {
for (const style of chunk.styles.filter((s) => s.style === "BOLD")) {
expect(style.start).toBeGreaterThanOrEqual(0);
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
}
}
});
it("style starting exactly at split point goes entirely to second chunk", () => {
// Create text where style starts right at where we'd split
const limit = 10;
const markdown = "abcdefghi **bold**";
const chunks = markdownToSignalTextChunks(markdown, limit);
expect(chunks.length).toBeGreaterThan(1);
// Find chunk with bold
const chunkWithBold = chunks.find((c) => c.styles.some((s) => s.style === "BOLD"));
expect(chunkWithBold).toBeDefined();
// Verify the bold style is valid within its chunk
const boldStyle = chunkWithBold!.styles.find((s) => s.style === "BOLD");
expect(boldStyle).toBeDefined();
expect(boldStyle!.start).toBeGreaterThanOrEqual(0);
expect(boldStyle!.start + boldStyle!.length).toBeLessThanOrEqual(chunkWithBold!.text.length);
});
it("style ending exactly at split point stays entirely in first chunk", () => {
const limit = 10;
const markdown = "**bold** rest of text";
const chunks = markdownToSignalTextChunks(markdown, limit);
// First chunk should have the complete bold style
const firstChunk = chunks[0];
if (firstChunk.text.includes("bold")) {
const boldStyle = firstChunk.styles.find((s) => s.style === "BOLD");
expect(boldStyle).toBeDefined();
expect(boldStyle!.start + boldStyle!.length).toBeLessThanOrEqual(firstChunk.text.length);
}
});
it("multiple styles, some spanning boundary, some not", () => {
const limit = 25;
// Mix of styles: italic at start, bold spanning boundary, monospace at end
const markdown = "_italic_ some text **bold text** and `code`";
const chunks = markdownToSignalTextChunks(markdown, limit);
expect(chunks.length).toBeGreaterThan(1);
// Verify all style ranges are valid within their respective chunks
expectChunkStyleRangesInBounds(chunks);
// Collect all styles across chunks
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
// We should have at least italic, bold, and monospace somewhere
expect(allStyles).toContain("ITALIC");
expect(allStyles).toContain("BOLD");
expect(allStyles).toContain("MONOSPACE");
});
});
describe("style-aware splitting - edge cases", () => {
it("handles zero-length text with styles gracefully", () => {
// Edge case: empty markdown produces no chunks
const chunks = markdownToSignalTextChunks("", 100);
expect(chunks).toHaveLength(0);
});
it("handles text that splits exactly at limit", () => {
const limit = 10;
const markdown = "1234567890"; // exactly 10 chars
const chunks = markdownToSignalTextChunks(markdown, limit);
expect(chunks).toHaveLength(1);
expect(chunks[0].text).toBe("1234567890");
});
it("preserves style through whitespace trimming", () => {
const limit = 30;
const markdown = "**bold** some text that is longer than limit";
const chunks = markdownToSignalTextChunks(markdown, limit);
// Bold should be preserved in first chunk
const firstChunk = chunks[0];
if (firstChunk.text.includes("bold")) {
expect(firstChunk.styles.some((s) => s.style === "BOLD")).toBe(true);
}
});
it("handles repeated substrings correctly (no indexOf fragility)", () => {
// This test exposes the fragility of using indexOf to find chunk positions.
// If the same substring appears multiple times, indexOf finds the first
// occurrence, not necessarily the correct one.
const limit = 20;
// "word" appears multiple times - indexOf("word") would always find first
const markdown = "word **bold word** word more text here to chunk";
const chunks = markdownToSignalTextChunks(markdown, limit);
// Verify chunks are under limit
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(limit);
}
// Find chunk(s) with bold style
const chunksWithBold = chunks.filter((c) => c.styles.some((s) => s.style === "BOLD"));
expect(chunksWithBold.length).toBeGreaterThanOrEqual(1);
// The bold style should correctly cover "bold word" (or part of it if split)
// and NOT incorrectly point to the first "word" in the text
for (const chunk of chunksWithBold) {
for (const style of chunk.styles.filter((s) => s.style === "BOLD")) {
const styledText = chunk.text.slice(style.start, style.start + style.length);
// The styled text should be part of "bold word", not the initial "word"
expect(styledText).toMatch(/^(bold( word)?|word)$/);
expect(style.start).toBeGreaterThanOrEqual(0);
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
}
}
});
it("handles chunk that starts with whitespace after split", () => {
// When text is split at whitespace, the next chunk might have leading
// whitespace trimmed. Styles must account for this.
const limit = 15;
const markdown = "some text **bold** at end";
const chunks = markdownToSignalTextChunks(markdown, limit);
// All style ranges must be valid
for (const chunk of chunks) {
for (const style of chunk.styles) {
expect(style.start).toBeGreaterThanOrEqual(0);
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
}
}
});
it("deterministically tracks position without indexOf fragility", () => {
// This test ensures the chunker doesn't rely on finding chunks via indexOf
// which can fail when chunkText trims whitespace or when duplicates exist.
// Create text with lots of whitespace and repeated patterns.
const limit = 25;
const markdown = "aaa **bold** aaa **bold** aaa extra text to force split";
const chunks = markdownToSignalTextChunks(markdown, limit);
// Multiple chunks expected
expect(chunks.length).toBeGreaterThan(1);
// All chunks should respect limit
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(limit);
}
// All style ranges must be valid within their chunks
for (const chunk of chunks) {
for (const style of chunk.styles) {
expect(style.start).toBeGreaterThanOrEqual(0);
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
// The styled text at that position should actually be "bold"
if (style.style === "BOLD") {
const styledText = chunk.text.slice(style.start, style.start + style.length);
expect(styledText).toBe("bold");
}
}
}
});
});
});
describe("markdownToSignalTextChunks", () => {
describe("link expansion chunk limit", () => {
it("does not exceed chunk limit after link expansion", () => {
// Create text that is close to limit, with a link that will expand
const limit = 100;
// Create text that's 90 chars, leaving only 10 chars of headroom
const filler = "x".repeat(80);
// This link will expand from "[link](url)" to "link (https://example.com/very/long/path)"
const markdown = `${filler} [link](https://example.com/very/long/path/that/will/exceed/limit)`;
const chunks = markdownToSignalTextChunks(markdown, limit);
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(limit);
}
});
it("handles multiple links near chunk boundary", () => {
const limit = 100;
const filler = "x".repeat(60);
const markdown = `${filler} [a](https://a.com) [b](https://b.com) [c](https://c.com)`;
const chunks = markdownToSignalTextChunks(markdown, limit);
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(limit);
}
});
});
describe("link expansion with style preservation", () => {
it("long message with links that expand beyond limit preserves all text", () => {
const limit = 80;
const filler = "a".repeat(50);
const markdown = `${filler} [click here](https://example.com/very/long/path/to/page) more text`;
const chunks = markdownToSignalTextChunks(markdown, limit);
// All chunks should be under limit
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(limit);
}
// Combined text should contain all original content
const combined = chunks.map((c) => c.text).join("");
expect(combined).toContain(filler);
expect(combined).toContain("click here");
expect(combined).toContain("example.com");
});
it("styles (bold, italic) survive chunking correctly after link expansion", () => {
const limit = 60;
const markdown =
"**bold start** text [link](https://example.com/path) _italic_ more content here to force chunking";
const chunks = markdownToSignalTextChunks(markdown, limit);
// Should have multiple chunks
expect(chunks.length).toBeGreaterThan(1);
// All style ranges should be valid within their chunks
expectChunkStyleRangesInBounds(chunks);
// Verify styles exist somewhere
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
expect(allStyles).toContain("BOLD");
expect(allStyles).toContain("ITALIC");
});
it("multiple links near chunk boundary all get properly chunked", () => {
const limit = 50;
const markdown =
"[first](https://first.com/long/path) [second](https://second.com/another/path) [third](https://third.com)";
const chunks = markdownToSignalTextChunks(markdown, limit);
// All chunks should respect limit
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(limit);
}
// All link labels should appear somewhere
const combined = chunks.map((c) => c.text).join("");
expect(combined).toContain("first");
expect(combined).toContain("second");
expect(combined).toContain("third");
});
it("preserves spoiler style through link expansion and chunking", () => {
const limit = 40;
const markdown =
"||secret content|| and [link](https://example.com/path) with more text to chunk";
const chunks = markdownToSignalTextChunks(markdown, limit);
// All chunks should respect limit
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(limit);
}
// Spoiler style should exist and be valid
const chunkWithSpoiler = chunks.find((c) => c.styles.some((s) => s.style === "SPOILER"));
expect(chunkWithSpoiler).toBeDefined();
const spoilerStyle = chunkWithSpoiler!.styles.find((s) => s.style === "SPOILER");
expect(spoilerStyle).toBeDefined();
expect(spoilerStyle!.start).toBeGreaterThanOrEqual(0);
expect(spoilerStyle!.start + spoilerStyle!.length).toBeLessThanOrEqual(
chunkWithSpoiler!.text.length,
);
});
});
});

View file

@ -0,0 +1,143 @@
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" }]);
});
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("shows URL when the label is only the domain but the URL has a path", () => {
const res = markdownToSignalText("[example.com](https://example.com/page)");
expect(res.text).toBe("example.com (https://example.com/page)");
});
});
describe("visual distinctions", () => {
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" });
});
it("renders blockquotes with a visible prefix", () => {
const res = markdownToSignalText("> This is a quote");
expect(res.text).toMatch(/^[│>]/);
expect(res.text).toContain("This is a quote");
});
it("renders multi-line blockquotes with a visible prefix", () => {
const res = markdownToSignalText("> Line 1\n> Line 2");
expect(res.text).toMatch(/^[│>]/);
expect(res.text).toContain("Line 1");
expect(res.text).toContain("Line 2");
});
it("renders horizontal rules as a visible separator", () => {
const res = markdownToSignalText("Para 1\n\n---\n\nPara 2");
expect(res.text).toMatch(/[─—-]{3,}/);
});
it("renders horizontal rules between content", () => {
const res = markdownToSignalText("Above\n\n***\n\nBelow");
expect(res.text).toContain("Above");
expect(res.text).toContain("Below");
expect(res.text).toMatch(/[─—-]{3,}/);
});
});
});

View file

@ -0,0 +1,267 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import {
markdownToIR,
normalizeLowercaseStringOrEmpty,
type MarkdownIR,
type MarkdownStyle,
renderMarkdownIRChunksWithinLimit,
} from "openclaw/plugin-sdk/text-runtime";
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 = normalizeLowercaseStringOrEmpty(url);
// 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);
}
export function markdownToSignalTextChunks(
markdown: string,
limit: number,
options: SignalMarkdownOptions = {},
): SignalFormattedText[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "bold",
blockquotePrefix: "> ",
tableMode: options.tableMode,
});
return renderMarkdownIRChunksWithinLimit({
ir,
limit,
renderChunk: renderSignalText,
measureRendered: (rendered) => rendered.text.length,
}).map(({ rendered }) => rendered);
}

View file

@ -0,0 +1,128 @@
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import { normalizeE164, normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { looksLikeUuid } from "./uuid.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 };
export { looksLikeUuid } from "./uuid.js";
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 = normalizeLowercaseStringOrEmpty(stripped);
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;
}

View file

@ -0,0 +1,193 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import * as tar from "tar";
import { describe, expect, it } from "vitest";
import type { ReleaseAsset } from "./install-signal-cli.js";
import { extractSignalCliArchive, looksLikeArchive, pickAsset } from "./install-signal-cli.js";
const SAMPLE_ASSETS: ReleaseAsset[] = [
{
name: "signal-cli-0.13.14-Linux-native.tar.gz",
browser_download_url: "https://example.com/linux-native.tar.gz",
},
{
name: "signal-cli-0.13.14-Linux-native.tar.gz.asc",
browser_download_url: "https://example.com/linux-native.tar.gz.asc",
},
{
name: "signal-cli-0.13.14-macOS-native.tar.gz",
browser_download_url: "https://example.com/macos-native.tar.gz",
},
{
name: "signal-cli-0.13.14-macOS-native.tar.gz.asc",
browser_download_url: "https://example.com/macos-native.tar.gz.asc",
},
{
name: "signal-cli-0.13.14-Windows-native.zip",
browser_download_url: "https://example.com/windows-native.zip",
},
{
name: "signal-cli-0.13.14-Windows-native.zip.asc",
browser_download_url: "https://example.com/windows-native.zip.asc",
},
{ name: "signal-cli-0.13.14.tar.gz", browser_download_url: "https://example.com/jvm.tar.gz" },
{
name: "signal-cli-0.13.14.tar.gz.asc",
browser_download_url: "https://example.com/jvm.tar.gz.asc",
},
];
describe("looksLikeArchive", () => {
it("recognises .tar.gz", () => {
expect(looksLikeArchive("foo.tar.gz")).toBe(true);
});
it("recognises .tgz", () => {
expect(looksLikeArchive("foo.tgz")).toBe(true);
});
it("recognises .zip", () => {
expect(looksLikeArchive("foo.zip")).toBe(true);
});
it("rejects signature files", () => {
expect(looksLikeArchive("foo.tar.gz.asc")).toBe(false);
});
it("rejects unrelated files", () => {
expect(looksLikeArchive("README.md")).toBe(false);
});
});
describe("pickAsset", () => {
describe("linux", () => {
it("selects the Linux-native asset on x64", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("Linux-native");
expect(result!.name).toMatch(/\.tar\.gz$/);
});
it("returns undefined on arm64 (triggers brew fallback)", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "arm64");
expect(result).toBeUndefined();
});
it("returns undefined on arm (32-bit)", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "arm");
expect(result).toBeUndefined();
});
});
describe("darwin", () => {
it("selects the macOS-native asset", () => {
const result = pickAsset(SAMPLE_ASSETS, "darwin", "arm64");
expect(result).toBeDefined();
expect(result!.name).toContain("macOS-native");
});
it("selects the macOS-native asset on x64", () => {
const result = pickAsset(SAMPLE_ASSETS, "darwin", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("macOS-native");
});
});
describe("win32", () => {
it("selects the Windows-native asset", () => {
const result = pickAsset(SAMPLE_ASSETS, "win32", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("Windows-native");
expect(result!.name).toMatch(/\.zip$/);
});
});
describe("edge cases", () => {
it("returns undefined for an empty asset list", () => {
expect(pickAsset([], "linux", "x64")).toBeUndefined();
});
it("skips assets with missing name or url", () => {
const partial: ReleaseAsset[] = [
{ name: "signal-cli.tar.gz" },
{ browser_download_url: "https://example.com/file.tar.gz" },
];
expect(pickAsset(partial, "linux", "x64")).toBeUndefined();
});
it("falls back to first archive for unknown platform", () => {
const result = pickAsset(SAMPLE_ASSETS, "freebsd" as NodeJS.Platform, "x64");
expect(result).toBeDefined();
expect(result!.name).toMatch(/\.tar\.gz$/);
});
it("never selects .asc signature files", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "x64");
expect(result).toBeDefined();
expect(result!.name).not.toMatch(/\.asc$/);
});
});
});
describe("extractSignalCliArchive", () => {
async function withArchiveWorkspace(run: (workDir: string) => Promise<void>) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-install-"));
try {
await run(workDir);
} finally {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
}
}
it("rejects zip slip path traversal", async () => {
await withArchiveWorkspace(async (workDir) => {
const archivePath = path.join(workDir, "bad.zip");
const extractDir = path.join(workDir, "extract");
await fs.mkdir(extractDir, { recursive: true });
const zip = new JSZip();
zip.file("../pwned.txt", "pwnd");
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
await expect(extractSignalCliArchive(archivePath, extractDir, 5_000)).rejects.toThrow(
/(escapes destination|absolute)/i,
);
});
});
it("extracts zip archives", async () => {
await withArchiveWorkspace(async (workDir) => {
const archivePath = path.join(workDir, "ok.zip");
const extractDir = path.join(workDir, "extract");
await fs.mkdir(extractDir, { recursive: true });
const zip = new JSZip();
zip.file("root/signal-cli", "bin");
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
await extractSignalCliArchive(archivePath, extractDir, 5_000);
const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8");
expect(extracted).toBe("bin");
});
});
it("extracts tar.gz archives", async () => {
await withArchiveWorkspace(async (workDir) => {
const archivePath = path.join(workDir, "ok.tgz");
const extractDir = path.join(workDir, "extract");
const rootDir = path.join(workDir, "root");
await fs.mkdir(rootDir, { recursive: true });
await fs.writeFile(path.join(rootDir, "signal-cli"), "bin", "utf-8");
await tar.c({ cwd: workDir, file: archivePath, gzip: true }, ["root"]);
await fs.mkdir(extractDir, { recursive: true });
await extractSignalCliArchive(archivePath, extractDir, 5_000);
const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8");
expect(extracted).toBe("bin");
});
});
});

View file

@ -0,0 +1,307 @@
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import { request } from "node:https";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/run-command";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { CONFIG_DIR, extractArchive, resolveBrewExecutable } from "openclaw/plugin-sdk/setup-tools";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
export type ReleaseAsset = {
name?: string;
browser_download_url?: string;
};
export type NamedAsset = {
name: string;
browser_download_url: string;
};
type ReleaseResponse = {
tag_name?: string;
assets?: ReleaseAsset[];
};
export type SignalInstallResult = {
ok: boolean;
cliPath?: string;
version?: string;
error?: string;
};
/** @internal Exported for testing. */
export async function extractSignalCliArchive(
archivePath: string,
installRoot: string,
timeoutMs: number,
): Promise<void> {
await extractArchive({ archivePath, destDir: installRoot, timeoutMs });
}
/** @internal Exported for testing. */
export function looksLikeArchive(name: string): boolean {
return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip");
}
/**
* Pick a native release asset from the official GitHub releases.
*
* The official signal-cli releases only publish native (GraalVM) binaries for
* x86-64 Linux. On architectures where no native asset is available this
* returns `undefined` so the caller can fall back to a different install
* strategy (e.g. Homebrew).
*/
/** @internal Exported for testing. */
export function pickAsset(
assets: ReleaseAsset[],
platform: NodeJS.Platform,
arch: string,
): NamedAsset | undefined {
const withName = assets.filter((asset): asset is NamedAsset =>
Boolean(asset.name && asset.browser_download_url),
);
// Archives only, excluding signature files (.asc)
const archives = withName.filter((a) =>
looksLikeArchive(normalizeLowercaseStringOrEmpty(a.name)),
);
const byName = (pattern: RegExp) =>
archives.find((asset) => pattern.test(normalizeLowercaseStringOrEmpty(asset.name)));
if (platform === "linux") {
// The official "Linux-native" asset is an x86-64 GraalVM binary.
// On non-x64 architectures it will fail with "Exec format error",
// so only select it when the host architecture matches.
if (arch === "x64") {
return byName(/linux-native/) || byName(/linux/) || archives[0];
}
// No native release for this arch — caller should fall back.
return undefined;
}
if (platform === "darwin") {
return byName(/macos|osx|darwin/) || archives[0];
}
if (platform === "win32") {
return byName(/windows|win/) || archives[0];
}
return archives[0];
}
async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise<void> {
await new Promise<void>((resolve, reject) => {
const req = request(url, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
const location = res.headers.location;
if (!location || maxRedirects <= 0) {
reject(new Error("Redirect loop or missing Location header"));
return;
}
const redirectUrl = new URL(location, url).href;
resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1));
return;
}
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`));
return;
}
const out = createWriteStream(dest);
pipeline(res, out).then(resolve).catch(reject);
});
req.on("error", reject);
req.end();
});
}
async function findSignalCliBinary(root: string): Promise<string | null> {
const candidates: string[] = [];
const enqueue = async (dir: string, depth: number) => {
if (depth > 3) {
return;
}
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await enqueue(full, depth + 1);
} else if (entry.isFile() && entry.name === "signal-cli") {
candidates.push(full);
}
}
};
await enqueue(root, 0);
return candidates[0] ?? null;
}
// ---------------------------------------------------------------------------
// Brew-based install (used on architectures without an official native build)
// ---------------------------------------------------------------------------
async function resolveBrewSignalCliPath(brewExe: string): Promise<string | null> {
try {
const result = await runPluginCommandWithTimeout({
argv: [brewExe, "--prefix", "signal-cli"],
timeoutMs: 10_000,
});
if (result.code === 0 && result.stdout.trim()) {
const prefix = result.stdout.trim();
// Homebrew installs the wrapper script at <prefix>/bin/signal-cli
const candidate = path.join(prefix, "bin", "signal-cli");
try {
await fs.access(candidate);
return candidate;
} catch {
// Fall back to searching the prefix
return findSignalCliBinary(prefix);
}
}
} catch {
// ignore
}
return null;
}
async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise<SignalInstallResult> {
const brewExe = resolveBrewExecutable();
if (!brewExe) {
return {
ok: false,
error:
`No native signal-cli build is available for ${process.arch}. ` +
"Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.",
};
}
runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`);
const result = await runPluginCommandWithTimeout({
argv: [brewExe, "install", "signal-cli"],
timeoutMs: 15 * 60_000, // brew builds from source; can take a while
});
if (result.code !== 0) {
return {
ok: false,
error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`,
};
}
const cliPath = await resolveBrewSignalCliPath(brewExe);
if (!cliPath) {
return {
ok: false,
error: "brew install succeeded but signal-cli binary was not found.",
};
}
// Extract version from the installed binary.
let version: string | undefined;
try {
const vResult = await runPluginCommandWithTimeout({
argv: [cliPath, "--version"],
timeoutMs: 10_000,
});
// Output is typically "signal-cli 0.13.24"
version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined;
} catch {
// non-critical; leave version undefined
}
return { ok: true, cliPath, version };
}
// ---------------------------------------------------------------------------
// Direct download install (used when an official native asset is available)
// ---------------------------------------------------------------------------
async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalInstallResult> {
const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest";
const response = await fetch(apiUrl, {
headers: {
"User-Agent": "openclaw",
Accept: "application/vnd.github+json",
},
});
if (!response.ok) {
return {
ok: false,
error: `Failed to fetch release info (${response.status})`,
};
}
const payload = (await response.json()) as ReleaseResponse;
const version = payload.tag_name?.replace(/^v/, "") ?? "unknown";
const assets = payload.assets ?? [];
const asset = pickAsset(assets, process.platform, process.arch);
if (!asset) {
return {
ok: false,
error: "No compatible release asset found for this platform.",
};
}
const tmpDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-signal-"));
const archivePath = path.join(tmpDir, asset.name);
runtime.log(`Downloading signal-cli ${version} (${asset.name})…`);
await downloadToFile(asset.browser_download_url, archivePath);
const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version);
await fs.mkdir(installRoot, { recursive: true });
if (!looksLikeArchive(normalizeLowercaseStringOrEmpty(asset.name))) {
return { ok: false, error: `Unsupported archive type: ${asset.name}` };
}
try {
await extractSignalCliArchive(archivePath, installRoot, 60_000);
} catch (err) {
const message = formatErrorMessage(err);
return {
ok: false,
error: `Failed to extract ${asset.name}: ${message}`,
};
}
const cliPath = await findSignalCliBinary(installRoot);
if (!cliPath) {
return {
ok: false,
error: `signal-cli binary not found after extracting ${asset.name}`,
};
}
await fs.chmod(cliPath, 0o755).catch(() => {});
return { ok: true, cliPath, version };
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
export async function installSignalCli(runtime: RuntimeEnv): Promise<SignalInstallResult> {
if (process.platform === "win32") {
return {
ok: false,
error: "Signal CLI auto-install is not supported on Windows yet.",
};
}
// The official signal-cli GitHub releases only ship a native binary for
// x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate
// to Homebrew which builds from source and bundles the JRE automatically.
const hasNativeRelease = process.platform !== "linux" || process.arch === "x64";
if (hasNativeRelease) {
return installSignalCliFromRelease(runtime);
}
return installSignalCliViaBrew(runtime);
}

View file

@ -0,0 +1,183 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendReactionsModule = await import("./send-reactions.js");
const sendReactionSignalMock = vi
.spyOn(sendReactionsModule, "sendReactionSignal")
.mockResolvedValue({ ok: true });
const removeReactionSignalMock = vi
.spyOn(sendReactionsModule, "removeReactionSignal")
.mockResolvedValue({ ok: true });
const { signalMessageActions } = await import("./message-actions.js");
function createSignalAccountOverrideCfg(): OpenClawConfig {
return {
channels: {
signal: {
account: "+15550002222",
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as OpenClawConfig;
}
describe("signalMessageActions", () => {
beforeEach(() => {
sendReactionSignalMock.mockClear();
removeReactionSignalMock.mockClear();
});
it("lists actions based on configured accounts and reaction gates", () => {
expect(
signalMessageActions.describeMessageTool?.({ cfg: {} as OpenClawConfig })?.actions ?? [],
).toEqual([]);
expect(
signalMessageActions.describeMessageTool?.({
cfg: {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as OpenClawConfig,
})?.actions,
).toEqual(["send"]);
expect(
signalMessageActions.describeMessageTool?.({ cfg: createSignalAccountOverrideCfg() })
?.actions,
).toEqual(["send", "react"]);
});
it("honors account-scoped reaction gates during discovery", () => {
const cfg = createSignalAccountOverrideCfg();
expect(
signalMessageActions.describeMessageTool?.({ cfg, accountId: "default" })?.actions,
).toEqual(["send"]);
expect(signalMessageActions.describeMessageTool?.({ cfg, accountId: "work" })?.actions).toEqual(
["send", "react"],
);
});
it("skips send for plugin dispatch", () => {
expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false);
expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true);
});
it("blocks reactions when the action gate is disabled", async () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as OpenClawConfig;
await expect(
signalMessageActions.handleAction?.({
channel: "signal",
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "✅" },
cfg,
}),
).rejects.toThrow(/actions\.reactions/);
});
it("maps reaction targets into sendReactionSignal calls", async () => {
const cases = [
{
name: "uses account-level actions when enabled",
cfg: createSignalAccountOverrideCfg(),
accountId: "work",
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
expectedRecipient: "+15550001111",
expectedTimestamp: 123,
expectedEmoji: "👍",
expectedOptions: { accountId: "work" },
},
{
name: "normalizes uuid recipients",
cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig,
params: {
recipient: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "🔥",
},
expectedRecipient: "123e4567-e89b-12d3-a456-426614174000",
expectedTimestamp: 123,
expectedEmoji: "🔥",
expectedOptions: {},
},
{
name: "passes groupId and targetAuthor for group reactions",
cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig,
params: {
to: "signal:group:group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "✅",
},
expectedRecipient: "",
expectedTimestamp: 123,
expectedEmoji: "✅",
expectedOptions: {
groupId: "group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
},
},
{
name: "falls back to toolContext.currentMessageId when messageId is omitted",
cfg: { channels: { signal: { account: "+15550001111" } } } as OpenClawConfig,
params: { to: "+15559999999", emoji: "🔥" },
expectedRecipient: "+15559999999",
expectedTimestamp: 1737630212345,
expectedEmoji: "🔥",
expectedOptions: {},
toolContext: { currentMessageId: "1737630212345" },
},
] as const;
for (const testCase of cases) {
sendReactionSignalMock.mockClear();
await signalMessageActions.handleAction?.({
channel: "signal",
action: "react",
params: testCase.params,
cfg: testCase.cfg,
accountId: "accountId" in testCase ? testCase.accountId : undefined,
toolContext: "toolContext" in testCase ? testCase.toolContext : undefined,
});
expect(sendReactionSignalMock, testCase.name).toHaveBeenCalledWith(
testCase.expectedRecipient,
testCase.expectedTimestamp,
testCase.expectedEmoji,
expect.objectContaining({
cfg: testCase.cfg,
...testCase.expectedOptions,
}),
);
}
});
it("rejects invalid reaction inputs before dispatch", async () => {
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as OpenClawConfig;
await expect(
signalMessageActions.handleAction?.({
channel: "signal",
action: "react",
params: { to: "+15559999999", emoji: "✅" },
cfg,
}),
).rejects.toThrow(/messageId.*required/);
await expect(
signalMessageActions.handleAction?.({
channel: "signal",
action: "react",
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
cfg,
}),
).rejects.toThrow(/targetAuthor/);
});
});

View file

@ -0,0 +1,187 @@
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
import { createActionGate, jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { removeReactionSignal, sendReactionSignal } from "../reaction-runtime-api.js";
import { listEnabledSignalAccounts, resolveSignalAccount } from "./accounts.js";
import { resolveSignalReactionLevel } from "./reaction-level.js";
const providerId = "signal";
const GROUP_PREFIX = "group:";
function normalizeSignalReactionRecipient(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return trimmed;
}
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) {
return withoutSignal;
}
if (normalizeLowercaseStringOrEmpty(withoutSignal).startsWith("uuid:")) {
return withoutSignal.slice("uuid:".length).trim();
}
return withoutSignal;
}
function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId?: string } {
const trimmed = raw.trim();
if (!trimmed) {
return {};
}
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) {
return {};
}
if (normalizeLowercaseStringOrEmpty(withoutSignal).startsWith(GROUP_PREFIX)) {
const groupId = withoutSignal.slice(GROUP_PREFIX.length).trim();
return groupId ? { groupId } : {};
}
return { recipient: normalizeSignalReactionRecipient(withoutSignal) };
}
async function mutateSignalReaction(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
accountId?: string;
target: { recipient?: string; groupId?: string };
timestamp: number;
emoji: string;
remove?: boolean;
targetAuthor?: string;
targetAuthorUuid?: string;
}) {
const options = {
cfg: params.cfg,
accountId: params.accountId,
groupId: params.target.groupId,
targetAuthor: params.targetAuthor,
targetAuthorUuid: params.targetAuthorUuid,
};
if (params.remove) {
await removeReactionSignal(
params.target.recipient ?? "",
params.timestamp,
params.emoji,
options,
);
return jsonResult({ ok: true, removed: params.emoji });
}
await sendReactionSignal(params.target.recipient ?? "", params.timestamp, params.emoji, options);
return jsonResult({ ok: true, added: params.emoji });
}
export const signalMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg, accountId }) => {
const configuredAccounts = accountId
? [resolveSignalAccount({ cfg, accountId })].filter(
(account) => account.enabled && account.configured,
)
: listEnabledSignalAccounts(cfg).filter((account) => account.configured);
if (configuredAccounts.length === 0) {
return null;
}
const actions = new Set<ChannelMessageActionName>(["send"]);
const reactionsEnabled = configuredAccounts.some((account) =>
createActionGate(account.config.actions)("reactions"),
);
if (reactionsEnabled) {
actions.add("react");
}
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => action !== "send",
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
if (action === "send") {
throw new Error("Send should be handled by outbound, not actions handler.");
}
if (action === "react") {
const reactionLevelInfo = resolveSignalReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`Signal agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.signal.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions;
const isActionEnabled = createActionGate(actionConfig);
if (!isActionEnabled("reactions")) {
throw new Error("Signal reactions are disabled via actions.reactions.");
}
const recipientRaw =
readStringParam(params, "recipient") ??
readStringParam(params, "to", {
required: true,
label: "recipient (UUID, phone number, or group)",
});
const target = resolveSignalReactionTarget(recipientRaw);
if (!target.recipient && !target.groupId) {
throw new Error("recipient or group required");
}
const messageIdRaw = resolveReactionMessageId({ args: params, toolContext });
const messageId = messageIdRaw != null ? String(messageIdRaw) : undefined;
if (!messageId) {
throw new Error(
"messageId (timestamp) required. Provide messageId explicitly or react to the current inbound message.",
);
}
const targetAuthor = readStringParam(params, "targetAuthor");
const targetAuthorUuid = readStringParam(params, "targetAuthorUuid");
if (target.groupId && !targetAuthor && !targetAuthorUuid) {
throw new Error("targetAuthor or targetAuthorUuid required for group reactions.");
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
const timestamp = parseInt(messageId, 10);
if (!Number.isFinite(timestamp)) {
throw new Error(`Invalid messageId: ${messageId}. Expected numeric timestamp.`);
}
if (remove) {
if (!emoji) {
throw new Error("Emoji required to remove reaction.");
}
return await mutateSignalReaction({
cfg,
accountId: accountId ?? undefined,
target,
timestamp,
emoji,
remove: true,
targetAuthor,
targetAuthorUuid,
});
}
if (!emoji) {
throw new Error("Emoji required to add reaction.");
}
return await mutateSignalReaction({
cfg,
accountId: accountId ?? undefined,
target,
timestamp,
emoji,
remove: false,
targetAuthor,
targetAuthorUuid,
});
}
throw new Error(`Action ${action} not supported for ${providerId}.`);
},
};

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { isSignalGroupAllowed } from "./identity.js";
describe("signal groupPolicy gating", () => {
it("allows when policy is open", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "open",
allowFrom: [],
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
}),
).toBe(true);
});
it("blocks when policy is disabled", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "disabled",
allowFrom: ["+15550001111"],
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
}),
).toBe(false);
});
it("blocks allowlist when empty", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: [],
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
}),
).toBe(false);
});
it("allows allowlist when sender matches", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["+15550001111"],
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
}),
).toBe(true);
});
it("allows allowlist wildcard", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["*"],
sender: { kind: "phone", raw: "+15550002222", e164: "+15550002222" },
}),
).toBe(true);
});
it("allows allowlist when uuid sender matches", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["uuid:123e4567-e89b-12d3-a456-426614174000"],
sender: {
kind: "uuid",
raw: "123e4567-e89b-12d3-a456-426614174000",
},
}),
).toBe(true);
});
});

View file

@ -0,0 +1,191 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it, vi } from "vitest";
import type { SignalDaemonExitEvent } from "./daemon.js";
import {
createSignalToolResultConfig,
createMockSignalDaemonHandle,
config,
getSignalToolResultTestMocks,
installSignalToolResultTestHooks,
setSignalToolResultTestConfig,
} from "./monitor.tool-result.test-harness.js";
installSignalToolResultTestHooks();
const { monitorSignalProvider } = await import("./monitor.js");
const { waitForTransportReadyMock, spawnSignalDaemonMock, streamMock } =
getSignalToolResultTestMocks();
const SIGNAL_BASE_URL = "http://127.0.0.1:8080";
type MonitorSignalProviderOptions = NonNullable<Parameters<typeof monitorSignalProvider>[0]>;
function createMonitorRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: ((code: number): never => {
throw new Error(`exit ${code}`);
}) as (code: number) => never,
};
}
function setSignalAutoStartConfig(overrides: Record<string, unknown> = {}) {
setSignalToolResultTestConfig(createSignalToolResultConfig(overrides));
}
function createAutoAbortController() {
const abortController = new AbortController();
streamMock.mockImplementation(async () => {
abortController.abort();
return;
});
return abortController;
}
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
return monitorSignalProvider({
config: config as OpenClawConfig,
waitForTransportReady:
waitForTransportReadyMock as MonitorSignalProviderOptions["waitForTransportReady"],
...opts,
});
}
function expectWaitForTransportReadyTimeout(timeoutMs: number) {
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs,
}),
);
}
describe("monitorSignalProvider autostart", () => {
it("uses bounded readiness checks when auto-starting the daemon", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig();
const abortController = createAutoAbortController();
await runMonitorWithMocks({
autoStart: true,
baseUrl: SIGNAL_BASE_URL,
abortSignal: abortController.signal,
runtime,
});
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
expect.objectContaining({
label: "signal daemon",
timeoutMs: 30_000,
logAfterMs: 10_000,
logIntervalMs: 10_000,
pollIntervalMs: 150,
runtime,
abortSignal: expect.any(AbortSignal),
}),
);
});
it("uses startupTimeoutMs override when provided", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig({ startupTimeoutMs: 60_000 });
const abortController = createAutoAbortController();
await runMonitorWithMocks({
autoStart: true,
baseUrl: SIGNAL_BASE_URL,
abortSignal: abortController.signal,
runtime,
startupTimeoutMs: 90_000,
});
expectWaitForTransportReadyTimeout(90_000);
});
it("caps startupTimeoutMs at 2 minutes", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig({ startupTimeoutMs: 180_000 });
const abortController = createAutoAbortController();
await runMonitorWithMocks({
autoStart: true,
baseUrl: SIGNAL_BASE_URL,
abortSignal: abortController.signal,
runtime,
});
expectWaitForTransportReadyTimeout(120_000);
});
it("fails fast when auto-started signal daemon exits during startup", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig();
spawnSignalDaemonMock.mockReturnValueOnce(
createMockSignalDaemonHandle({
exited: Promise.resolve({ source: "process", code: 1, signal: null }),
isExited: () => true,
}),
);
waitForTransportReadyMock.mockImplementationOnce(
async (params: { abortSignal?: AbortSignal | null }) => {
await new Promise<void>((_resolve, reject) => {
if (params.abortSignal?.aborted) {
reject(params.abortSignal.reason);
return;
}
params.abortSignal?.addEventListener(
"abort",
() => reject(params.abortSignal?.reason ?? new Error("aborted")),
{ once: true },
);
});
},
);
await expect(
runMonitorWithMocks({
autoStart: true,
baseUrl: SIGNAL_BASE_URL,
runtime,
}),
).rejects.toThrow(/signal daemon exited/i);
});
it("treats daemon exit after user abort as clean shutdown", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig();
const abortController = new AbortController();
let exited = false;
let resolveExit!: (value: SignalDaemonExitEvent) => void;
const exitedPromise = new Promise<SignalDaemonExitEvent>((resolve) => {
resolveExit = resolve;
});
const stop = vi.fn(() => {
if (exited) {
return;
}
exited = true;
resolveExit({ source: "process", code: null, signal: "SIGTERM" });
});
spawnSignalDaemonMock.mockReturnValueOnce(
createMockSignalDaemonHandle({
stop,
exited: exitedPromise,
isExited: () => exited,
}),
);
streamMock.mockImplementationOnce(async () => {
abortController.abort(new Error("stop"));
});
await expect(
runMonitorWithMocks({
autoStart: true,
baseUrl: SIGNAL_BASE_URL,
runtime,
abortSignal: abortController.signal,
}),
).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import {
config,
flush,
getSignalToolResultTestMocks,
installSignalToolResultTestHooks,
setSignalToolResultTestConfig,
} from "./monitor.tool-result.test-harness.js";
installSignalToolResultTestHooks();
const { monitorSignalProvider } = await import("./monitor.js");
const { replyMock, sendMock, streamMock, upsertPairingRequestMock } =
getSignalToolResultTestMocks();
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
return monitorSignalProvider(opts);
}
describe("monitorSignalProvider tool results", () => {
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
const baseChannels = (config.channels ?? {}) as Record<string, unknown>;
const baseSignal = (baseChannels.signal ?? {}) as Record<string, unknown>;
setSignalToolResultTestConfig({
...config,
channels: {
...baseChannels,
signal: {
...baseSignal,
autoStart: false,
dmPolicy: "pairing",
allowFrom: [],
},
},
});
const abortController = new AbortController();
const uuid = "123e4567-e89b-12d3-a456-426614174000";
streamMock.mockImplementation(async ({ onEvent }) => {
const payload = {
envelope: {
sourceUuid: uuid,
sourceName: "Ada",
timestamp: 1,
dataMessage: {
message: "hello",
},
},
};
await onEvent({
event: "receive",
data: JSON.stringify(payload),
});
abortController.abort();
});
await runMonitorWithMocks({
autoStart: false,
baseUrl: "http://127.0.0.1:8080",
abortSignal: abortController.signal,
});
await flush();
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "signal",
id: `uuid:${uuid}`,
meta: expect.objectContaining({ name: "Ada" }),
}),
);
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0]?.[0]).toBe(`signal:${uuid}`);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
`Your Signal sender id: uuid:${uuid}`,
);
});
it("reconnects after stream errors until aborted", async () => {
vi.useFakeTimers();
const abortController = new AbortController();
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
let calls = 0;
streamMock.mockImplementation(async () => {
calls += 1;
if (calls === 1) {
throw new Error("stream dropped");
}
abortController.abort();
});
try {
const monitorPromise = monitorSignalProvider({
autoStart: false,
baseUrl: "http://127.0.0.1:8080",
abortSignal: abortController.signal,
reconnectPolicy: {
initialMs: 1,
maxMs: 1,
factor: 1,
jitter: 0,
},
});
await vi.advanceTimersByTimeAsync(5);
await monitorPromise;
expect(streamMock).toHaveBeenCalledTimes(2);
} finally {
randomSpy.mockRestore();
vi.useRealTimers();
}
});
});

View file

@ -0,0 +1,335 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { describe, expect, it, vi } from "vitest";
import { expectPairingReplyText } from "../../../test/helpers/pairing-reply.js";
import {
createSignalToolResultConfig,
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,
enqueueSystemEventMock,
upsertPairingRequestMock,
waitForTransportReadyMock,
} = getSignalToolResultTestMocks();
const SIGNAL_BASE_URL = "http://127.0.0.1:8080";
type MonitorSignalProviderOptions = NonNullable<Parameters<typeof monitorSignalProvider>[0]>;
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
return monitorSignalProvider({
config: config as OpenClawConfig,
waitForTransportReady:
waitForTransportReadyMock as MonitorSignalProviderOptions["waitForTransportReady"],
...opts,
});
}
async function receiveSignalPayloads(params: {
payloads: unknown[];
opts?: Partial<MonitorSignalProviderOptions>;
}) {
const abortController = new AbortController();
streamMock.mockImplementation(async ({ onEvent }) => {
for (const payload of params.payloads) {
await onEvent({
event: "receive",
data: JSON.stringify(payload),
});
}
abortController.abort();
});
await runMonitorWithMocks({
autoStart: false,
baseUrl: SIGNAL_BASE_URL,
abortSignal: abortController.signal,
...params.opts,
});
await flush();
}
function hasQueuedReactionEventFor(sender: string) {
const route = resolveAgentRoute({
cfg: config as OpenClawConfig,
channel: "signal",
accountId: "default",
peer: { kind: "direct", id: normalizeE164(sender) },
});
return enqueueSystemEventMock.mock.calls.some(([text, options]) => {
return (
typeof text === "string" &&
text.includes("Signal reaction added") &&
typeof options === "object" &&
options !== null &&
"sessionKey" in options &&
(options as { sessionKey?: string }).sessionKey === route.sessionKey
);
});
}
function makeBaseEnvelope(overrides: Record<string, unknown> = {}) {
return {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1,
...overrides,
};
}
async function receiveSingleEnvelope(
envelope: Record<string, unknown>,
opts?: Partial<MonitorSignalProviderOptions>,
) {
await receiveSignalPayloads({
payloads: [{ envelope }],
opts,
});
}
function expectNoReplyDeliveryOrRouteUpdate() {
expect(replyMock).not.toHaveBeenCalled();
expect(sendMock).not.toHaveBeenCalled();
expect(updateLastRouteMock).not.toHaveBeenCalled();
}
function setReactionNotificationConfig(mode: "all" | "own", extra: Record<string, unknown> = {}) {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
dmPolicy: "open",
allowFrom: ["*"],
reactionNotifications: mode,
...extra,
}),
);
}
describe("monitorSignalProvider tool results", () => {
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",
},
},
},
],
});
await vi.waitFor(() => {
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(
createSignalToolResultConfig({ 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);
expectPairingReplyText(String(sendMock.mock.calls[0]?.[1] ?? ""), {
channel: "signal",
idLine: "Your Signal number: +15550001111",
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,
},
});
expect(hasQueuedReactionEventFor("+15550001111")).toBe(true);
});
it.each([
{
name: "blocks reaction notifications from unauthorized senders when dmPolicy is allowlist",
mode: "all" as const,
extra: { dmPolicy: "allowlist", allowFrom: ["+15550007777"] } as Record<string, unknown>,
targetAuthor: "+15550002222",
shouldEnqueue: false,
},
{
name: "blocks reaction notifications from unauthorized senders when dmPolicy is pairing",
mode: "own" as const,
extra: {
dmPolicy: "pairing",
allowFrom: [],
account: "+15550009999",
} as Record<string, unknown>,
targetAuthor: "+15550009999",
shouldEnqueue: false,
},
{
name: "allows reaction notifications for allowlisted senders when dmPolicy is allowlist",
mode: "all" as const,
extra: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } as Record<string, unknown>,
targetAuthor: "+15550002222",
shouldEnqueue: true,
},
])("$name", async ({ mode, extra, targetAuthor, shouldEnqueue }) => {
setReactionNotificationConfig(mode, extra);
await receiveSingleEnvelope({
...makeBaseEnvelope(),
reactionMessage: {
emoji: "✅",
targetAuthor,
targetSentTimestamp: 2,
},
});
expect(hasQueuedReactionEventFor("+15550001111")).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,
},
});
expect(hasQueuedReactionEventFor("+15550001111")).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",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
});
it("does not resend pairing code when a request is already pending", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({ 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);
});
});

View file

@ -0,0 +1,233 @@
import type { MockFn } from "openclaw/plugin-sdk/testing";
import { beforeEach, vi } from "vitest";
import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js";
type SignalToolResultTestMocks = {
waitForTransportReadyMock: MockFn;
enqueueSystemEventMock: 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 enqueueSystemEventMock = 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;
const signalToolResultSessionStorePath = vi.hoisted(
() => `/tmp/openclaw-signal-tool-result-sessions-${process.pid}.json`,
);
export function getSignalToolResultTestMocks(): SignalToolResultTestMocks {
return {
waitForTransportReadyMock,
enqueueSystemEventMock,
sendMock,
replyMock,
updateLastRouteMock,
readAllowFromStoreMock,
upsertPairingRequestMock,
streamMock,
signalCheckMock,
signalRpcRequestMock,
spawnSignalDaemonMock,
};
}
export let config: Record<string, unknown> = {};
export function setSignalToolResultTestConfig(next: Record<string, unknown>) {
config = next;
}
export function createSignalToolResultConfig(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
const base = config as { channels?: Record<string, unknown> };
const channels = base.channels ?? {};
const signal = (channels.signal ?? {}) as Record<string, unknown>;
return {
...base,
channels: {
...channels,
signal: {
...signal,
autoStart: true,
dmPolicy: "open",
allowFrom: ["*"],
...overrides,
},
},
};
}
export async function flush() {
await Promise.resolve();
await Promise.resolve();
}
export function createMockSignalDaemonHandle(
overrides: {
stop?: MockFn;
exited?: Promise<SignalDaemonExitEvent>;
isExited?: () => boolean;
} = {},
): SignalDaemonHandle {
const stop = overrides.stop ?? (vi.fn() as unknown as MockFn);
const exited = overrides.exited ?? new Promise<SignalDaemonExitEvent>(() => {});
const isExited = overrides.isExited ?? (() => false);
return {
stop: stop as unknown as () => void,
exited,
isExited,
};
}
// Use importActual so shared-worker mocks from earlier test files do not leak
// into this harness's partial overrides.
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
loadConfig: () => config,
resolveStorePath: vi.fn(() => signalToolResultSessionStorePath),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
"openclaw/plugin-sdk/reply-runtime",
);
return {
...actual,
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
dispatchInboundMessage: async (params: {
ctx: unknown;
cfg: unknown;
dispatcher: {
sendFinalReply: (payload: { text: string }) => boolean;
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
}) => {
const resolved = (await replyMock(params.ctx, {}, params.cfg)) as
| { text?: string }
| undefined;
const text = typeof resolved?.text === "string" ? resolved.text.trim() : "";
if (text) {
params.dispatcher.sendFinalReply({ text });
}
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
return { queuedFinal: Boolean(text) };
},
};
});
vi.mock("./send.js", async () => {
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
return {
...actual,
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
sendTypingSignal: vi.fn().mockResolvedValue(true),
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
);
return {
...actual,
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/security-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/security-runtime")>(
"openclaw/plugin-sdk/security-runtime",
);
return {
...actual,
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
};
});
vi.mock("./client.js", () => ({
streamSignalEvents: (...args: unknown[]) => streamMock(...args),
signalCheck: (...args: unknown[]) => signalCheckMock(...args),
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
}));
vi.mock("./daemon.js", async () => {
const actual = await vi.importActual<typeof import("./daemon.js")>("./daemon.js");
return {
...actual,
spawnSignalDaemon: (...args: unknown[]) => spawnSignalDaemonMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/infra-runtime")>(
"openclaw/plugin-sdk/infra-runtime",
);
return {
...actual,
enqueueSystemEvent: (...args: Parameters<typeof actual.enqueueSystemEvent>) => {
enqueueSystemEventMock(...args);
return actual.enqueueSystemEvent(...args);
},
waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args),
};
});
export function installSignalToolResultTestHooks() {
beforeEach(async () => {
const [{ resetInboundDedupe }, { resetSystemEventsForTest }] = await Promise.all([
import("openclaw/plugin-sdk/reply-runtime"),
import("openclaw/plugin-sdk/infra-runtime"),
]);
resetInboundDedupe();
config = {
messages: { responsePrefix: "PFX" },
session: { store: signalToolResultSessionStorePath },
channels: {
signal: { autoStart: false, dmPolicy: "open", allowFrom: ["*"] },
},
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
streamMock.mockReset();
signalCheckMock.mockReset().mockResolvedValue({ ok: true });
signalRpcRequestMock.mockReset().mockResolvedValue({});
spawnSignalDaemonMock.mockReset().mockReturnValue(createMockSignalDaemonHandle());
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
enqueueSystemEventMock.mockReset();
resetSystemEventsForTest();
});
}

View file

@ -0,0 +1,502 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import { estimateBase64DecodedBytes, saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import {
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "openclaw/plugin-sdk/reply-runtime";
import {
createNonExitingRuntime,
type BackoffPolicy,
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import {
normalizeE164,
normalizeOptionalString,
normalizeStringEntries,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveSignalAccount } from "./accounts.js";
import { signalCheck, signalRpcRequest } from "./client.js";
import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
import { createSignalEventHandler } from "./monitor/event-handler.js";
import type {
SignalAttachment,
SignalReactionMessage,
SignalReactionTarget,
} from "./monitor/event-handler.types.js";
import { sendMessageSignal } from "./send.js";
import { runSignalSseLoop } from "./sse-reconnect.js";
export type MonitorSignalOpts = {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
account?: string;
accountId?: string;
config?: OpenClawConfig;
baseUrl?: string;
autoStart?: boolean;
startupTimeoutMs?: number;
cliPath?: string;
httpHost?: string;
httpPort?: number;
receiveMode?: "on-start" | "manual";
ignoreAttachments?: boolean;
ignoreStories?: boolean;
sendReadReceipts?: boolean;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
mediaMaxMb?: number;
reconnectPolicy?: Partial<BackoffPolicy>;
waitForTransportReady?: typeof waitForTransportReady;
};
function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
return opts.runtime ?? createNonExitingRuntime();
}
function mergeAbortSignals(
a?: AbortSignal,
b?: AbortSignal,
): { signal?: AbortSignal; dispose: () => void } {
if (!a && !b) {
return { signal: undefined, dispose: () => {} };
}
if (!a) {
return { signal: b, dispose: () => {} };
}
if (!b) {
return { signal: a, dispose: () => {} };
}
const controller = new AbortController();
const abortFrom = (source: AbortSignal) => {
if (!controller.signal.aborted) {
controller.abort(source.reason);
}
};
if (a.aborted) {
abortFrom(a);
return { signal: controller.signal, dispose: () => {} };
}
if (b.aborted) {
abortFrom(b);
return { signal: controller.signal, dispose: () => {} };
}
const onAbortA = () => abortFrom(a);
const onAbortB = () => abortFrom(b);
a.addEventListener("abort", onAbortA, { once: true });
b.addEventListener("abort", onAbortB, { once: true });
return {
signal: controller.signal,
dispose: () => {
a.removeEventListener("abort", onAbortA);
b.removeEventListener("abort", onAbortB);
},
};
}
function createSignalDaemonLifecycle(params: { abortSignal?: AbortSignal }) {
let daemonHandle: SignalDaemonHandle | null = null;
let daemonStopRequested = false;
let daemonExitError: Error | undefined;
const daemonAbortController = new AbortController();
const mergedAbort = mergeAbortSignals(params.abortSignal, daemonAbortController.signal);
const stop = () => {
daemonStopRequested = true;
daemonHandle?.stop();
};
const attach = (handle: SignalDaemonHandle) => {
daemonHandle = handle;
void handle.exited.then((exit) => {
if (daemonStopRequested || params.abortSignal?.aborted) {
return;
}
daemonExitError = new Error(formatSignalDaemonExit(exit));
if (!daemonAbortController.signal.aborted) {
daemonAbortController.abort(daemonExitError);
}
});
};
const getExitError = () => daemonExitError;
return {
attach,
stop,
getExitError,
abortSignal: mergedAbort.signal,
dispose: mergedAbort.dispose,
};
}
function normalizeAllowList(raw?: Array<string | number>): string[] {
return normalizeStringEntries(raw);
}
function resolveSignalReactionTargets(reaction: SignalReactionMessage): SignalReactionTarget[] {
const targets: SignalReactionTarget[] = [];
const uuid = reaction.targetAuthorUuid?.trim();
if (uuid) {
targets.push({ kind: "uuid", id: uuid, display: `uuid:${uuid}` });
}
const author = reaction.targetAuthor?.trim();
if (author) {
const normalized = normalizeE164(author);
targets.push({ kind: "phone", id: normalized, display: normalized });
}
return targets;
}
function isSignalReactionMessage(
reaction: SignalReactionMessage | null | undefined,
): reaction is SignalReactionMessage {
if (!reaction) {
return false;
}
const emoji = reaction.emoji?.trim();
const timestamp = reaction.targetSentTimestamp;
const hasTarget = Boolean(
normalizeOptionalString(reaction.targetAuthor) ||
normalizeOptionalString(reaction.targetAuthorUuid),
);
return Boolean(emoji && typeof timestamp === "number" && timestamp > 0 && hasTarget);
}
function shouldEmitSignalReactionNotification(params: {
mode?: SignalReactionNotificationMode;
account?: string | null;
targets?: SignalReactionTarget[];
sender?: ReturnType<typeof resolveSignalSender> | null;
allowlist?: string[];
}) {
const { mode, account, targets, sender, allowlist } = params;
const effectiveMode = mode ?? "own";
if (effectiveMode === "off") {
return false;
}
if (effectiveMode === "own") {
const accountId = account?.trim();
if (!accountId || !targets || targets.length === 0) {
return false;
}
const normalizedAccount = normalizeE164(accountId);
return targets.some((target) => {
if (target.kind === "uuid") {
return accountId === target.id || accountId === `uuid:${target.id}`;
}
return normalizedAccount === target.id;
});
}
if (effectiveMode === "allowlist") {
if (!sender || !allowlist || allowlist.length === 0) {
return false;
}
return isSignalSenderAllowed(sender, allowlist);
}
return true;
}
function buildSignalReactionSystemEventText(params: {
emojiLabel: string;
actorLabel: string;
messageId: string;
targetLabel?: string;
groupLabel?: string;
}) {
const base = `Signal reaction added: ${params.emojiLabel} by ${params.actorLabel} msg ${params.messageId}`;
const withTarget = params.targetLabel ? `${base} from ${params.targetLabel}` : base;
return params.groupLabel ? `${withTarget} in ${params.groupLabel}` : withTarget;
}
async function waitForSignalDaemonReady(params: {
baseUrl: string;
abortSignal?: AbortSignal;
timeoutMs: number;
logAfterMs: number;
logIntervalMs?: number;
runtime: RuntimeEnv;
waitForTransportReadyFn?: typeof waitForTransportReady;
}): Promise<void> {
const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady;
await waitForTransportReadyFn({
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 (typeof attachment.size === "number" && attachment.size > params.maxBytes) {
throw new Error(
`Signal attachment ${attachment.id} exceeds ${(params.maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
);
}
const rpcParams: Record<string, unknown> = {
id: attachment.id,
};
if (params.account) {
rpcParams.account = params.account;
}
if (params.groupId) {
rpcParams.groupId = params.groupId;
} else if (params.sender) {
rpcParams.recipient = params.sender;
} else {
return null;
}
const result = await signalRpcRequest<{ data?: string }>("getAttachment", rpcParams, {
baseUrl: params.baseUrl,
});
if (!result?.data) {
return null;
}
if (estimateBase64DecodedBytes(result.data) > params.maxBytes) {
throw new Error(
`Signal attachment ${attachment.id} exceeds ${(params.maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
);
}
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 reply = resolveSendableOutboundReplyParts(payload);
const delivered = await deliverTextOrMediaReply({
payload,
text: reply.text,
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
sendText: async (chunk) => {
await sendMessageSignal(target, chunk, {
baseUrl,
account,
maxBytes,
accountId,
});
},
sendMedia: async ({ mediaUrl, caption }) => {
await sendMessageSignal(target, caption ?? "", {
baseUrl,
account,
mediaUrl,
maxBytes,
accountId,
});
},
});
if (delivered !== "empty") {
runtime.log?.(`delivered reply to ${target}`);
}
}
}
export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = opts.config ?? loadConfig();
const accountInfo = resolveSignalAccount({
cfg,
accountId: opts.accountId,
});
const historyLimit = Math.max(
0,
accountInfo.config.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const groupHistories = new Map<string, HistoryEntry[]>();
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
const chunkMode = resolveChunkMode(cfg, "signal", accountInfo.accountId);
const baseUrl = normalizeOptionalString(opts.baseUrl) ?? accountInfo.baseUrl;
const account =
normalizeOptionalString(opts.account) ?? normalizeOptionalString(accountInfo.config.account);
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 waitForTransportReadyFn = opts.waitForTransportReady ?? waitForTransportReady;
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 = 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,
waitForTransportReadyFn,
});
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();
}
}

View file

@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { handleSignalDirectMessageAccess } from "./access-policy.js";
describe("handleSignalDirectMessageAccess", () => {
it("returns true for already-allowed direct messages", async () => {
await expect(
handleSignalDirectMessageAccess({
dmPolicy: "open",
dmAccessDecision: "allow",
senderId: "+15551230000",
senderIdLine: "Signal number: +15551230000",
senderDisplay: "Alice",
accountId: "default",
sendPairingReply: async () => {},
log: () => {},
}),
).resolves.toBe(true);
});
it("issues a pairing challenge for pairing-gated senders", async () => {
const replies: string[] = [];
const sendPairingReply = vi.fn(async (text: string) => {
replies.push(text);
});
await expect(
handleSignalDirectMessageAccess({
dmPolicy: "pairing",
dmAccessDecision: "pairing",
senderId: "+15551230000",
senderIdLine: "Signal number: +15551230000",
senderDisplay: "Alice",
senderName: "Alice",
accountId: "default",
sendPairingReply,
log: () => {},
}),
).resolves.toBe(false);
expect(sendPairingReply).toHaveBeenCalledTimes(1);
expect(replies[0]).toContain("Pairing code:");
});
});

View file

@ -0,0 +1,88 @@
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk/security-runtime";
import { isSignalSenderAllowed, type SignalSender } from "../identity.js";
type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
type SignalGroupPolicy = "open" | "allowlist" | "disabled";
export async function resolveSignalAccessState(params: {
accountId: string;
dmPolicy: SignalDmPolicy;
groupPolicy: SignalGroupPolicy;
allowFrom: string[];
groupAllowFrom: string[];
sender: SignalSender;
}) {
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "signal",
accountId: params.accountId,
dmPolicy: params.dmPolicy,
});
const resolveAccessDecision = (isGroup: boolean) =>
resolveDmGroupAccessWithLists({
isGroup,
dmPolicy: params.dmPolicy,
groupPolicy: params.groupPolicy,
allowFrom: params.allowFrom,
groupAllowFrom: params.groupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowEntries) => isSignalSenderAllowed(params.sender, allowEntries),
});
const dmAccess = resolveAccessDecision(false);
return {
resolveAccessDecision,
dmAccess,
effectiveDmAllow: dmAccess.effectiveAllowFrom,
effectiveGroupAllow: dmAccess.effectiveGroupAllowFrom,
};
}
export async function handleSignalDirectMessageAccess(params: {
dmPolicy: SignalDmPolicy;
dmAccessDecision: "allow" | "block" | "pairing";
senderId: string;
senderIdLine: string;
senderDisplay: string;
senderName?: string;
accountId: string;
sendPairingReply: (text: string) => Promise<void>;
log: (message: string) => void;
}): Promise<boolean> {
if (params.dmAccessDecision === "allow") {
return true;
}
if (params.dmAccessDecision === "block") {
if (params.dmPolicy !== "disabled") {
params.log(`Blocked signal sender ${params.senderDisplay} (dmPolicy=${params.dmPolicy})`);
}
return false;
}
if (params.dmPolicy === "pairing") {
await createChannelPairingChallengeIssuer({
channel: "signal",
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "signal",
id,
accountId: params.accountId,
meta,
}),
})({
senderId: params.senderId,
senderIdLine: params.senderIdLine,
meta: { name: params.senderName },
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;
}

View file

@ -0,0 +1,336 @@
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/test-helpers.js";
vi.useRealTimers();
const [
{ createBaseSignalEventHandlerDeps, createSignalReceiveEvent },
{ createSignalEventHandler },
] = await Promise.all([import("./event-handler.test-harness.js"), import("./event-handler.js")]);
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
() => {
const captureState: { ctx?: MsgContext } = {};
return {
sendTypingMock: vi.fn(),
sendReadReceiptMock: vi.fn(),
dispatchInboundMessageMock: vi.fn(
async (params: {
ctx: MsgContext;
replyOptions?: { onReplyStart?: () => void | Promise<void> };
}) => {
captureState.ctx = params.ctx;
await Promise.resolve(params.replyOptions?.onReplyStart?.());
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
},
),
capture: captureState,
};
},
);
vi.mock("../send.js", () => ({
sendMessageSignal: vi.fn(),
sendTypingSignal: sendTypingMock,
sendReadReceiptSignal: sendReadReceiptMock,
}));
vi.mock("../../../../src/auto-reply/dispatch.js", async () => {
const actual = await vi.importActual<typeof import("../../../../src/auto-reply/dispatch.js")>(
"../../../../src/auto-reply/dispatch.js",
);
return {
...actual,
dispatchInboundMessage: dispatchInboundMessageMock,
dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock,
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock,
};
});
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
upsertChannelPairingRequest: vi.fn(),
}));
describe("signal createSignalEventHandler inbound context", () => {
beforeEach(() => {
delete capture.ctx;
sendTypingMock.mockReset().mockResolvedValue(true);
sendReadReceiptMock.mockReset().mockResolvedValue(true);
dispatchInboundMessageMock.mockClear();
});
it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
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();
const contextWithBody = capture.ctx;
if (!contextWithBody) {
throw new Error("expected inbound MsgContext");
}
expectInboundContextContract(contextWithBody);
// 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({
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("drops quote-only group context from non-allowlisted quoted senders in allowlist mode", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: {
messages: { inbound: { debounceMs: 0 } },
channels: {
signal: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15550001111"],
contextVisibility: "allowlist",
},
},
},
groupPolicy: "allowlist",
groupAllowFrom: ["+15550001111"],
historyLimit: 0,
}),
);
await handler(
createSignalReceiveEvent({
dataMessage: {
message: "",
quote: { text: "blocked quote", author: "+15550002222" },
groupInfo: { groupId: "g1", groupName: "Test Group" },
attachments: [],
},
}),
);
expect(capture.ctx).toBeUndefined();
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
});
it("keeps quote-only group context in allowlist_quote mode", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: {
messages: { inbound: { debounceMs: 0 } },
channels: {
signal: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15550001111"],
contextVisibility: "allowlist_quote",
},
},
},
groupPolicy: "allowlist",
groupAllowFrom: ["+15550001111"],
historyLimit: 0,
}),
);
await handler(
createSignalReceiveEvent({
dataMessage: {
message: "",
quote: { text: "quoted context", author: "+15550002222" },
groupInfo: { groupId: "g1", groupName: "Test Group" },
attachments: [],
},
}),
);
expect(capture.ctx).toBeTruthy();
expect(capture.ctx?.BodyForAgent).toBe("quoted context");
expect(capture.ctx?.ReplyToBody).toBe("quoted context");
expect(capture.ctx?.ReplyToSender).toBe("+15550002222");
expect(capture.ctx?.ReplyToIsQuote).toBe(true);
});
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();
});
});

View file

@ -0,0 +1,299 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildDispatchInboundCaptureMock } from "../../../../src/channels/plugins/contracts/inbound-testkit.js";
type SignalMsgContext = Pick<MsgContext, "Body" | "WasMentioned"> & {
Body?: string;
WasMentioned?: boolean;
};
let capturedCtx: SignalMsgContext | undefined;
function getCapturedCtx() {
return capturedCtx as SignalMsgContext;
}
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
"openclaw/plugin-sdk/reply-runtime",
);
return buildDispatchInboundCaptureMock(actual, (ctx) => {
capturedCtx = ctx as SignalMsgContext;
});
});
const [
{ createBaseSignalEventHandlerDeps, createSignalReceiveEvent },
{ createSignalEventHandler },
{ renderSignalMentions },
] = await Promise.all([
import("./event-handler.test-harness.js"),
import("./event-handler.js"),
import("./mentions.js"),
]);
type GroupEventOpts = {
message?: string;
attachments?: unknown[];
quoteText?: string;
mentions?: Array<{
uuid?: string;
number?: string;
start?: number;
length?: number;
}> | null;
};
function makeGroupEvent(opts: GroupEventOpts) {
return createSignalReceiveEvent({
dataMessage: {
message: opts.message ?? "",
attachments: opts.attachments ?? [],
quote: opts.quoteText ? { text: opts.quoteText } : undefined,
mentions: opts.mentions ?? undefined,
groupInfo: { groupId: "g1", groupName: "Test Group" },
},
});
}
function createMentionHandler(params: {
requireMention: boolean;
mentionPattern?: string;
historyLimit?: number;
groupHistories?: ReturnType<typeof createBaseSignalEventHandlerDeps>["groupHistories"];
}) {
return createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: createSignalConfig({
requireMention: params.requireMention,
mentionPattern: params.mentionPattern,
}),
...(typeof params.historyLimit === "number" ? { historyLimit: params.historyLimit } : {}),
...(params.groupHistories ? { groupHistories: params.groupHistories } : {}),
}),
);
}
function createMentionGatedHistoryHandler() {
const groupHistories = new Map();
const handler = createMentionHandler({ requireMention: true, historyLimit: 5, groupHistories });
return { handler, groupHistories };
}
function createSignalConfig(params: { requireMention: boolean; mentionPattern?: string }) {
return {
messages: {
inbound: { debounceMs: 0 },
groupChat: { mentionPatterns: [params.mentionPattern ?? "@bot"] },
},
channels: {
signal: {
groups: { "*": { requireMention: params.requireMention } },
},
},
} as unknown as OpenClawConfig;
}
async function expectSkippedGroupHistory(opts: GroupEventOpts, expectedBody: string) {
capturedCtx = undefined;
const { handler, groupHistories } = createMentionGatedHistoryHandler();
await handler(makeGroupEvent(opts));
expect(capturedCtx).toBeUndefined();
const entries = groupHistories.get("g1");
expect(entries).toBeTruthy();
expect(entries).toHaveLength(1);
expect(entries[0].body).toBe(expectedBody);
}
describe("signal mention gating", () => {
beforeEach(() => {
capturedCtx = undefined;
});
it("drops group messages without mention when requireMention is configured", async () => {
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 () => {
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 () => {
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 () => {
const { handler, groupHistories } = createMentionGatedHistoryHandler();
await handler(makeGroupEvent({ message: "hello from alice" }));
expect(capturedCtx).toBeUndefined();
const entries = groupHistories.get("g1");
expect(entries).toHaveLength(1);
expect(entries[0].sender).toBe("Alice");
expect(entries[0].body).toBe("hello from alice");
});
it("records attachment placeholder in pending history for skipped attachment-only group messages", async () => {
await expectSkippedGroupHistory(
{ message: "", attachments: [{ id: "a1" }] },
"<media:attachment>",
);
});
it("normalizes mixed-case parameterized attachment MIME in skipped pending history", async () => {
const groupHistories = new Map();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: createSignalConfig({ requireMention: true }),
historyLimit: 5,
groupHistories,
ignoreAttachments: false,
}),
);
await handler(
makeGroupEvent({
message: "",
attachments: [{ contentType: " Audio/Ogg; codecs=opus " }],
}),
);
expect(capturedCtx).toBeUndefined();
const entries = groupHistories.get("g1");
expect(entries).toHaveLength(1);
expect(entries[0].body).toBe("<media:audio>");
});
it("summarizes multiple skipped attachments with stable file count wording", async () => {
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 () => {
const handler = createMentionHandler({ requireMention: true });
await handler(makeGroupEvent({ message: "/help" }));
expect(capturedCtx).toBeTruthy();
});
it("hydrates mention placeholders before trimming so offsets stay aligned", async () => {
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 () => {
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");
});
});

View file

@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import {
createBaseSignalEventHandlerDeps,
createSignalReceiveEvent,
} from "./event-handler.test-harness.js";
const internalHookMocks = vi.hoisted(() => ({
createInternalHookEvent: vi.fn(
(type: string, action: string, sessionKey: string, context: Record<string, unknown>) => ({
type,
action,
sessionKey,
context,
timestamp: new Date(),
messages: [],
}),
),
triggerInternalHook: vi.fn(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/hook-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/hook-runtime")>(
"openclaw/plugin-sdk/hook-runtime",
);
return {
...actual,
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
triggerInternalHook: internalHookMocks.triggerInternalHook,
};
});
import { createSignalEventHandler } from "./event-handler.js";
describe("signal mention-skip silent ingest", () => {
it("emits internal message:received when ingest is enabled", async () => {
internalHookMocks.createInternalHookEvent.mockClear();
internalHookMocks.triggerInternalHook.mockClear();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: {
messages: {
groupChat: { mentionPatterns: ["@bot"] },
},
channels: {
signal: {
groups: {
"*": {
requireMention: true,
ingest: true,
},
},
},
},
} as never,
}),
);
await handler(
createSignalReceiveEvent({
dataMessage: {
message: "hello without mention",
attachments: [],
groupInfo: { groupId: "group-123", groupName: "Ops" },
},
}),
);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
"message",
"received",
expect.stringContaining("signal"),
expect.objectContaining({
channelId: "signal",
content: "hello without mention",
}),
);
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
});
it("does not emit when group ingest is false and wildcard ingest is true", async () => {
internalHookMocks.createInternalHookEvent.mockClear();
internalHookMocks.triggerInternalHook.mockClear();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: {
messages: {
groupChat: { mentionPatterns: ["@bot"] },
},
channels: {
signal: {
groups: {
"group-123": {
requireMention: true,
ingest: false,
},
"*": {
requireMention: true,
ingest: true,
},
},
},
},
} as never,
}),
);
await handler(
createSignalReceiveEvent({
dataMessage: {
message: "hello without mention",
attachments: [],
groupInfo: { groupId: "group-123", groupName: "Ops" },
},
}),
);
expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalled();
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,48 @@
import type { SignalEventHandlerDeps, SignalReactionMessage } from "./event-handler.types.js";
export function createBaseSignalEventHandlerDeps(
overrides: Partial<SignalEventHandlerDeps> = {},
): SignalEventHandlerDeps {
return {
runtime: { log: () => {}, error: () => {} } as any,
cfg: {},
baseUrl: "http://localhost",
accountId: "default",
historyLimit: 5,
groupHistories: new Map(),
textLimit: 4000,
dmPolicy: "open",
allowFrom: ["*"],
groupAllowFrom: ["*"],
groupPolicy: "open",
reactionMode: "off",
reactionAllowlist: [],
mediaMaxBytes: 1024,
ignoreAttachments: true,
sendReadReceipts: false,
readReceiptsViaDaemon: false,
fetchAttachment: async () => null,
deliverReplies: async () => {},
resolveSignalReactionTargets: () => [],
isSignalReactionMessage: (
_reaction: SignalReactionMessage | null | undefined,
): _reaction is SignalReactionMessage => false,
shouldEmitSignalReactionNotification: () => false,
buildSignalReactionSystemEventText: () => "reaction",
...overrides,
};
}
export function createSignalReceiveEvent(envelopeOverrides: Record<string, unknown> = {}) {
return {
event: "receive",
data: JSON.stringify({
envelope: {
sourceNumber: "+15550001111",
sourceName: "Alice",
timestamp: 1700000000000,
...envelopeOverrides,
},
}),
};
}

View file

@ -0,0 +1,876 @@
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import {
buildMentionRegexes,
createChannelInboundDebouncer,
formatInboundEnvelope,
formatInboundFromLabel,
matchesMentionPatterns,
resolveInboundMentionDecision,
resolveEnvelopeFormatOptions,
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import {
createInternalHookEvent,
fireAndForgetHook,
toInternalMessageReceivedContext,
triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
recordPendingHistoryEntryIfEnabled,
} from "openclaw/plugin-sdk/reply-history";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
DM_GROUP_ACCESS_REASON,
resolvePinnedMainDmOwnerFromAllowlist,
} from "openclaw/plugin-sdk/security-runtime";
import { normalizeE164, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
formatSignalPairingIdLine,
formatSignalSenderDisplay,
formatSignalSenderId,
isSignalSenderAllowed,
normalizeSignalAllowRecipient,
resolveSignalPeerId,
resolveSignalRecipient,
resolveSignalSender,
type SignalSender,
} from "../identity.js";
import { normalizeSignalMessagingTarget } from "../normalize.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 { resolveSignalQuoteContext } from "./inbound-context.js";
import { renderSignalMentions } from "./mentions.js";
function formatAttachmentKindCount(kind: string, count: number): string {
if (kind === "attachment") {
return `${count} file${count > 1 ? "s" : ""}`;
}
return `${count} ${kind}${count > 1 ? "s" : ""}`;
}
function formatAttachmentSummaryPlaceholder(contentTypes: Array<string | undefined>): string {
const kindCounts = new Map<string, number>();
for (const contentType of contentTypes) {
const kind = kindFromMime(contentType) ?? "attachment";
kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1);
}
const parts = [...kindCounts.entries()].map(([kind, count]) =>
formatAttachmentKindCount(kind, count),
);
return `[${parts.join(" + ")} attached]`;
}
function resolveSignalInboundRoute(params: {
cfg: SignalEventHandlerDeps["cfg"];
accountId: SignalEventHandlerDeps["accountId"];
isGroup: boolean;
groupId?: string;
senderPeerId: string;
}) {
return resolveAgentRoute({
cfg: params.cfg,
channel: "signal",
accountId: params.accountId,
peer: {
kind: params.isGroup ? "group" : "direct",
id: params.isGroup ? (params.groupId ?? "unknown") : params.senderPeerId,
},
});
}
export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
type SignalInboundEntry = {
senderName: string;
senderDisplay: string;
senderRecipient: string;
senderPeerId: string;
groupId?: string;
groupName?: string;
isGroup: boolean;
bodyText: string;
commandBody: string;
timestamp?: number;
messageId?: string;
mediaPath?: string;
mediaType?: string;
mediaPaths?: string[];
mediaTypes?: string[];
commandAuthorized: boolean;
wasMentioned?: boolean;
replyToBody?: string;
replyToSender?: string;
replyToIsQuote?: 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 ? (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,
ReplyToBody: entry.replyToBody,
ReplyToSender: entry.replyToSender,
ReplyToIsQuote: entry.replyToIsQuote,
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, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
cfg: deps.cfg,
agentId: route.agentId,
channel: "signal",
accountId: route.accountId,
typing: {
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({
...replyPipeline,
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
typingCallbacks,
deliver: async (payload) => {
await deps.deliverReplies({
replies: [payload],
target: ctxPayload.To,
baseUrl: deps.baseUrl,
account: deps.account,
accountId: deps.accountId,
runtime: deps.runtime,
maxBytes: deps.mediaMaxBytes,
textLimit: deps.textLimit,
});
},
onError: (err, info) => {
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
},
});
const { queuedFinal } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg: deps.cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected,
},
});
markDispatchIdle();
if (!queuedFinal) {
if (entry.isGroup && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: deps.groupHistories,
historyKey,
limit: deps.historyLimit,
});
}
return;
}
if (entry.isGroup && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: deps.groupHistories,
historyKey,
limit: deps.historyLimit,
});
}
}
const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<SignalInboundEntry>({
cfg: deps.cfg,
channel: "signal",
buildKey: (entry) => {
const conversationId = entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId;
if (!conversationId || !entry.senderPeerId) {
return null;
}
return `signal:${deps.accountId}:${conversationId}:${entry.senderPeerId}`;
},
shouldDebounce: (entry) => {
return shouldDebounceTextInbound({
text: entry.bodyText,
cfg: deps.cfg,
hasMedia: Boolean(entry.mediaPath || entry.mediaType || entry.mediaPaths?.length),
});
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
await handleSignalInboundMessage(last);
return;
}
const combinedText = entries
.map((entry) => entry.bodyText)
.filter(Boolean)
.join("\\n");
if (!combinedText.trim()) {
return;
}
await handleSignalInboundMessage({
...last,
bodyText: combinedText,
mediaPath: undefined,
mediaType: undefined,
mediaPaths: undefined,
mediaTypes: undefined,
});
},
onError: (err) => {
deps.runtime.error?.(`signal debounce flush failed: ${String(err)}`);
},
});
function handleReactionOnlyInbound(params: {
envelope: SignalEnvelope;
sender: SignalSender;
senderDisplay: string;
reaction: SignalReactionMessage;
hasBodyContent: boolean;
resolveAccessDecision: (isGroup: boolean) => {
decision: "allow" | "block" | "pairing";
reason: string;
};
}): boolean {
if (params.hasBodyContent) {
return false;
}
if (params.reaction.isRemove) {
return true; // Ignore reaction removals
}
const emojiLabel = normalizeOptionalString(params.reaction.emoji) ?? "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 groupId = dataMessage?.groupInfo?.groupId ?? undefined;
const isGroup = Boolean(groupId);
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,
});
const quoteText = normalizeOptionalString(dataMessage?.quote?.text) ?? "";
const { contextVisibilityMode, quoteSenderAllowed, visibleQuoteText, visibleQuoteSender } =
resolveSignalQuoteContext({
cfg: deps.cfg,
accountId: deps.accountId,
isGroup,
dataMessage,
effectiveGroupAllow,
});
if (quoteText && !visibleQuoteText && isGroup) {
logVerbose(
`signal: drop quote context (mode=${contextVisibilityMode}, sender_allowed=${quoteSenderAllowed ? "yes" : "no"})`,
);
}
const hasBodyContent =
Boolean(messageText || visibleQuoteText) ||
Boolean(!reaction && dataMessage?.attachments?.length);
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 groupName = dataMessage.groupInfo?.groupName ?? undefined;
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 mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned,
hasAnyMention: false,
implicitMentionKinds: [],
},
policy: {
isGroup,
requireMention,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionDecision.shouldSkip) {
logInboundDrop({
log: logVerbose,
channel: "signal",
reason: "no mention",
target: senderDisplay,
});
const pendingPlaceholder = (() => {
if (!dataMessage.attachments?.length) {
return "";
}
// When we're skipping a message we intentionally avoid downloading attachments.
// Still record a useful placeholder for pending-history context.
if (deps.ignoreAttachments) {
return "<media:attachment>";
}
const attachmentTypes = (dataMessage.attachments ?? []).map((attachment) =>
typeof attachment?.contentType === "string" ? attachment.contentType : undefined,
);
if (attachmentTypes.length > 1) {
return formatAttachmentSummaryPlaceholder(attachmentTypes);
}
const firstContentType = dataMessage.attachments?.[0]?.contentType;
const pendingKind = kindFromMime(firstContentType ?? undefined);
return pendingKind ? `<media:${pendingKind}>` : "<media:attachment>";
})();
const pendingBodyText = messageText || pendingPlaceholder || visibleQuoteText;
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,
},
});
const signalGroupPolicy = resolveChannelGroupPolicy({
cfg: deps.cfg,
channel: "signal",
groupId,
accountId: deps.accountId,
});
if (
(signalGroupPolicy.groupConfig?.ingest ?? signalGroupPolicy.defaultConfig?.ingest) === true
) {
const canonicalGroupTarget =
normalizeSignalMessagingTarget(`group:${groupId}`) ?? `group:${groupId}`;
fireAndForgetHook(
triggerInternalHook(
createInternalHookEvent(
"message",
"received",
route.sessionKey,
toInternalMessageReceivedContext({
from: `group:${groupId}`,
to: canonicalGroupTarget,
content: pendingBodyText,
timestamp: envelope.timestamp ?? undefined,
channelId: "signal",
accountId: deps.accountId,
conversationId: canonicalGroupTarget,
messageId:
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined,
senderId: senderDisplay,
senderName: envelope.sourceName ?? undefined,
provider: "signal",
surface: "signal",
originatingChannel: "signal",
originatingTo: canonicalGroupTarget,
isGroup: true,
groupId: canonicalGroupTarget,
}),
),
),
"signal: mention-skip message hook failed",
);
}
return;
}
let mediaPath: string | undefined;
let mediaType: string | undefined;
const mediaPaths: string[] = [];
const mediaTypes: string[] = [];
let placeholder = "";
const attachments = dataMessage.attachments ?? [];
if (!deps.ignoreAttachments) {
for (const attachment of attachments) {
if (!attachment?.id) {
continue;
}
try {
const fetched = await deps.fetchAttachment({
baseUrl: deps.baseUrl,
account: deps.account,
attachment,
sender: senderRecipient,
groupId,
maxBytes: deps.mediaMaxBytes,
});
if (fetched) {
mediaPaths.push(fetched.path);
mediaTypes.push(
fetched.contentType ?? attachment.contentType ?? "application/octet-stream",
);
if (!mediaPath) {
mediaPath = fetched.path;
mediaType = fetched.contentType ?? attachment.contentType ?? undefined;
}
}
} catch (err) {
deps.runtime.error?.(danger(`attachment fetch failed: ${String(err)}`));
}
}
}
if (mediaPaths.length > 1) {
placeholder = formatAttachmentSummaryPlaceholder(mediaTypes);
} else {
const kind = kindFromMime(mediaType ?? undefined);
if (kind) {
placeholder = `<media:${kind}>`;
} else if (attachments.length) {
placeholder = "<media:attachment>";
}
}
const bodyText = messageText || placeholder || visibleQuoteText || "";
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,
replyToBody: visibleQuoteText || undefined,
replyToSender: visibleQuoteSender,
replyToIsQuote: visibleQuoteText ? true : undefined,
});
};
}

View file

@ -0,0 +1,135 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
DmPolicy,
GroupPolicy,
SignalReactionNotificationMode,
} from "openclaw/plugin-sdk/config-runtime";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { SignalSender } from "../identity.js";
export type SignalEnvelope = {
sourceNumber?: string | null;
sourceUuid?: string | null;
sourceName?: string | null;
timestamp?: number | null;
dataMessage?: SignalDataMessage | null;
editMessage?: { dataMessage?: SignalDataMessage | null } | null;
syncMessage?: unknown;
reactionMessage?: SignalReactionMessage | null;
};
export type SignalMention = {
name?: string | null;
number?: string | null;
uuid?: string | null;
start?: number | null;
length?: number | null;
};
export type SignalDataMessage = {
timestamp?: number;
message?: string | null;
attachments?: Array<SignalAttachment>;
mentions?: Array<SignalMention> | null;
groupInfo?: {
groupId?: string | null;
groupName?: string | null;
} | null;
quote?: {
text?: string | null;
author?: string | null;
authorUuid?: string | null;
} | null;
reaction?: SignalReactionMessage | null;
};
export type SignalReactionMessage = {
emoji?: string | null;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
targetSentTimestamp?: number | null;
isRemove?: boolean | null;
groupInfo?: {
groupId?: string | null;
groupName?: string | null;
} | null;
};
export type SignalAttachment = {
id?: string | null;
contentType?: string | null;
filename?: string | null;
size?: number | null;
};
export type SignalReactionTarget = {
kind: "phone" | "uuid";
id: string;
display: string;
};
export type SignalReceivePayload = {
envelope?: SignalEnvelope | null;
exception?: { message?: string } | null;
};
export type SignalEventHandlerDeps = {
runtime: RuntimeEnv;
cfg: OpenClawConfig;
baseUrl: string;
account?: string;
accountUuid?: string;
accountId: string;
blockStreaming?: boolean;
historyLimit: number;
groupHistories: Map<string, HistoryEntry[]>;
textLimit: number;
dmPolicy: DmPolicy;
allowFrom: string[];
groupAllowFrom: string[];
groupPolicy: GroupPolicy;
reactionMode: SignalReactionNotificationMode;
reactionAllowlist: string[];
mediaMaxBytes: number;
ignoreAttachments: boolean;
sendReadReceipts: boolean;
readReceiptsViaDaemon: boolean;
fetchAttachment: (params: {
baseUrl: string;
account?: string;
attachment: SignalAttachment;
sender?: string;
groupId?: string;
maxBytes: number;
}) => Promise<{ path: string; contentType?: string } | null>;
deliverReplies: (params: {
replies: ReplyPayload[];
target: string;
baseUrl: string;
account?: string;
accountId?: string;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
}) => Promise<void>;
resolveSignalReactionTargets: (reaction: SignalReactionMessage) => SignalReactionTarget[];
isSignalReactionMessage: (
reaction: SignalReactionMessage | null | undefined,
) => reaction is SignalReactionMessage;
shouldEmitSignalReactionNotification: (params: {
mode?: SignalReactionNotificationMode;
account?: string | null;
targets?: SignalReactionTarget[];
sender?: SignalSender | null;
allowlist?: string[];
}) => boolean;
buildSignalReactionSystemEventText: (params: {
emojiLabel: string;
actorLabel: string;
messageId: string;
targetLabel?: string;
groupLabel?: string;
}) => string;
};

View file

@ -0,0 +1,59 @@
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
import {
evaluateSupplementalContextVisibility,
type ContextVisibilityDecision,
} from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
formatSignalSenderDisplay,
isSignalSenderAllowed,
resolveSignalSender,
} from "../identity.js";
import type { SignalDataMessage } from "./event-handler.types.js";
export type SignalQuoteContext = {
contextVisibilityMode: ReturnType<typeof resolveChannelContextVisibilityMode>;
decision: ContextVisibilityDecision;
quoteSenderAllowed: boolean;
visibleQuoteText: string;
visibleQuoteSender?: string;
};
export function resolveSignalQuoteContext(params: {
cfg: Parameters<typeof resolveChannelContextVisibilityMode>[0]["cfg"];
accountId: string;
isGroup: boolean;
dataMessage?: SignalDataMessage | null;
effectiveGroupAllow: string[];
}): SignalQuoteContext {
const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg: params.cfg,
channel: "signal",
accountId: params.accountId,
});
const quoteText = normalizeOptionalString(params.dataMessage?.quote?.text) ?? "";
const quoteSender = resolveSignalSender({
sourceNumber: params.dataMessage?.quote?.author ?? null,
sourceUuid: params.dataMessage?.quote?.authorUuid ?? null,
});
const quoteSenderAllowed =
!params.isGroup || params.effectiveGroupAllow.length === 0
? true
: quoteSender
? isSignalSenderAllowed(quoteSender, params.effectiveGroupAllow)
: false;
const decision = evaluateSupplementalContextVisibility({
mode: contextVisibilityMode,
kind: "quote",
senderAllowed: quoteSenderAllowed,
});
return {
contextVisibilityMode,
decision,
quoteSenderAllowed,
visibleQuoteText: decision.include ? quoteText : "",
visibleQuoteSender:
decision.include && quoteSender ? formatSignalSenderDisplay(quoteSender) : undefined,
};
}

View file

@ -0,0 +1,56 @@
import type { SignalMention } from "./event-handler.types.js";
const OBJECT_REPLACEMENT = "\uFFFC";
function isValidMention(mention: SignalMention | null | undefined): mention is SignalMention {
if (!mention) {
return false;
}
if (!(mention.uuid || mention.number)) {
return false;
}
if (typeof mention.start !== "number" || Number.isNaN(mention.start)) {
return false;
}
if (typeof mention.length !== "number" || Number.isNaN(mention.length)) {
return false;
}
return mention.length > 0;
}
function clampBounds(start: number, length: number, textLength: number) {
const safeStart = Math.max(0, Math.trunc(start));
const safeLength = Math.max(0, Math.trunc(length));
const safeEnd = Math.min(textLength, safeStart + safeLength);
return { start: safeStart, end: safeEnd };
}
export function renderSignalMentions(message: string, mentions?: SignalMention[] | null) {
if (!message || !mentions?.length) {
return message;
}
let normalized = message;
const candidates = mentions.filter(isValidMention).toSorted((a, b) => b.start! - a.start!);
for (const mention of candidates) {
const identifier = mention.uuid ?? mention.number;
if (!identifier) {
continue;
}
const { start, end } = clampBounds(mention.start!, mention.length!, normalized.length);
if (start >= end) {
continue;
}
const slice = normalized.slice(start, end);
if (!slice.includes(OBJECT_REPLACEMENT)) {
continue;
}
normalized = normalized.slice(0, start) + `@${identifier}` + normalized.slice(end);
}
return normalized;
}

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
describe("normalizeSignalMessagingTarget", () => {
it("normalizes uuid targets by stripping uuid:", () => {
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("normalizes signal:uuid targets", () => {
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("preserves case for group targets", () => {
expect(
normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="),
).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=");
});
it("preserves case for base64-like group IDs without signal prefix", () => {
expect(
normalizeSignalMessagingTarget("group:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/ABCD="),
).toBe("group:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789+/ABCD=");
});
});
describe("looksLikeSignalTargetId", () => {
it("accepts uuid prefixes for target detection", () => {
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
});
it("accepts signal-prefixed E.164 targets for detection", () => {
expect(looksLikeSignalTargetId("signal:+15551234567")).toBe(true);
expect(looksLikeSignalTargetId("signal:15551234567")).toBe(true);
});
it("accepts compact UUIDs for target detection", () => {
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
});
it("rejects invalid uuid prefixes", () => {
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
});
});

View file

@ -0,0 +1,69 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
export function normalizeSignalMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
let normalized = trimmed;
if (normalizeLowercaseStringOrEmpty(normalized).startsWith("signal:")) {
normalized = normalized.slice("signal:".length).trim();
}
if (!normalized) {
return undefined;
}
const lower = normalizeLowercaseStringOrEmpty(normalized);
if (lower.startsWith("group:")) {
const id = normalized.slice("group:".length).trim();
return id ? `group:${id}` : undefined;
}
if (lower.startsWith("username:")) {
const id = normalized.slice("username:".length).trim();
return id ? normalizeLowercaseStringOrEmpty(`username:${id}`) : undefined;
}
if (lower.startsWith("u:")) {
const id = normalized.slice("u:".length).trim();
return id ? normalizeLowercaseStringOrEmpty(`username:${id}`) : undefined;
}
if (lower.startsWith("uuid:")) {
const id = normalized.slice("uuid:".length).trim();
return id ? normalizeLowercaseStringOrEmpty(id) : undefined;
}
return normalizeLowercaseStringOrEmpty(normalized);
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i;
export function looksLikeSignalTargetId(raw: string, normalized?: string): boolean {
const candidates = [raw, normalized ?? ""].map((value) => value.trim()).filter(Boolean);
for (const candidate of candidates) {
if (/^(signal:)?(group:|username:|u:)/i.test(candidate)) {
return true;
}
if (/^(signal:)?uuid:/i.test(candidate)) {
const stripped = candidate
.replace(/^signal:/i, "")
.replace(/^uuid:/i, "")
.trim();
if (!stripped) {
continue;
}
if (UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped)) {
return true;
}
continue;
}
const withoutSignalPrefix = candidate.replace(/^signal:/i, "").trim();
if (UUID_PATTERN.test(withoutSignalPrefix) || UUID_COMPACT_PATTERN.test(withoutSignalPrefix)) {
return true;
}
if (/^\+?\d{3,}$/.test(withoutSignalPrefix)) {
return true;
}
}
return false;
}

View file

@ -0,0 +1,54 @@
import { type RoutePeer } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveSignalPeerId, resolveSignalRecipient, resolveSignalSender } from "./identity.js";
import { looksLikeUuid } from "./uuid.js";
export type ResolvedSignalOutboundTarget = {
peer: RoutePeer;
chatType: "direct" | "group";
from: string;
to: string;
};
export function resolveSignalOutboundTarget(target: string): ResolvedSignalOutboundTarget | null {
const stripped = target.replace(/^signal:/i, "").trim();
const lowered = normalizeLowercaseStringOrEmpty(stripped);
if (lowered.startsWith("group:")) {
const groupId = stripped.slice("group:".length).trim();
if (!groupId) {
return null;
}
return {
peer: { kind: "group", id: groupId },
chatType: "group",
from: `group:${groupId}`,
to: `group:${groupId}`,
};
}
let recipient = stripped.trim();
if (lowered.startsWith("username:")) {
recipient = stripped.slice("username:".length).trim();
} else if (lowered.startsWith("u:")) {
recipient = stripped.slice("u:".length).trim();
}
if (!recipient) {
return null;
}
const uuidCandidate = normalizeLowercaseStringOrEmpty(recipient).startsWith("uuid:")
? recipient.slice("uuid:".length)
: recipient;
const sender = resolveSignalSender({
sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null,
sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient,
});
const peerId = sender ? resolveSignalPeerId(sender) : recipient;
const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient;
return {
peer: { kind: "direct", id: peerId },
chatType: "direct",
from: `signal:${displayRecipient}`,
to: `signal:${displayRecipient}`,
};
}

View file

@ -0,0 +1,57 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { signalCheck, signalRpcRequest } from "./client.js";
export type SignalProbe = BaseProbeResult & {
status?: number | null;
elapsedMs: number;
version?: string | null;
};
function parseSignalVersion(value: unknown): string | null {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "object" && value !== null) {
const version = (value as { version?: unknown }).version;
if (typeof version === "string" && version.trim()) {
return version.trim();
}
}
return null;
}
export async function probeSignal(baseUrl: string, timeoutMs: number): Promise<SignalProbe> {
const started = Date.now();
const result: SignalProbe = {
ok: false,
status: null,
error: null,
elapsedMs: 0,
version: null,
};
const check = await signalCheck(baseUrl, timeoutMs);
if (!check.ok) {
return {
...result,
status: check.status ?? null,
error: check.error ?? "unreachable",
elapsedMs: Date.now() - started,
};
}
try {
const version = await signalRpcRequest("version", undefined, {
baseUrl,
timeoutMs,
});
result.version = parseSignalVersion(version);
} catch (err) {
result.error = formatErrorMessage(err);
}
return {
...result,
ok: true,
status: check.status ?? null,
elapsedMs: Date.now() - started,
};
}

View file

@ -0,0 +1,34 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveReactionLevel,
type ReactionLevel,
type ResolvedReactionLevel,
} from "openclaw/plugin-sdk/text-runtime";
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",
});
}

View file

@ -0,0 +1,22 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveSignalAccount } from "./accounts.js";
export function resolveSignalRpcContext(
opts: { baseUrl?: string; account?: string; accountId?: string },
accountInfo?: ReturnType<typeof resolveSignalAccount>,
) {
const hasBaseUrl = Boolean(normalizeOptionalString(opts.baseUrl));
const hasAccount = Boolean(normalizeOptionalString(opts.account));
if ((!hasBaseUrl || !hasAccount) && !accountInfo) {
throw new Error("Signal account config is required when baseUrl or account is missing");
}
const resolvedAccount = accountInfo;
const baseUrl = normalizeOptionalString(opts.baseUrl) ?? resolvedAccount?.baseUrl;
if (!baseUrl) {
throw new Error("Signal base URL is required");
}
const account =
normalizeOptionalString(opts.account) ??
normalizeOptionalString(resolvedAccount?.config.account);
return { baseUrl, account };
}

View file

@ -0,0 +1,52 @@
// Private runtime barrel for the bundled Signal extension.
// Prefer narrower SDK subpaths plus local extension seams over the legacy signal barrel.
export type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
export { buildChannelConfigSchema, SignalConfigSchema } from "../config-api.js";
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
import type { OpenClawConfig as RuntimeOpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export type { RuntimeOpenClawConfig as OpenClawConfig };
export type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/core";
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
export {
DEFAULT_ACCOUNT_ID,
applyAccountNameToChannelSection,
deleteAccountFromConfigSection,
emptyPluginConfigSchema,
formatPairingApproveHint,
getChatChannelMeta,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/core";
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
export { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
export { chunkText } from "openclaw/plugin-sdk/reply-runtime";
export { detectBinary } from "openclaw/plugin-sdk/setup-tools";
export {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
} from "openclaw/plugin-sdk/config-runtime";
export {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
collectStatusIssuesFromLastError,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
export { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
export { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
export {
listEnabledSignalAccounts,
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
} from "./accounts.js";
export { monitorSignalProvider } from "./monitor.js";
export { installSignalCli } from "./install-signal-cli.js";
export { probeSignal } from "./probe.js";
export { resolveSignalReactionLevel } from "./reaction-level.js";
export { removeReactionSignal, sendReactionSignal } from "./send-reactions.js";
export { sendMessageSignal } from "./send.js";
export { signalMessageActions } from "./message-actions.js";
export type { ResolvedSignalAccount } from "./accounts.js";
export type { SignalAccountConfig } from "./account-types.js";

View file

@ -0,0 +1,12 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
const {
setRuntime: setSignalRuntime,
clearRuntime: clearSignalRuntime,
getRuntime: getSignalRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "signal",
errorMessage: "Signal runtime not initialized",
});
export { clearSignalRuntime, getSignalRuntime, setSignalRuntime };

View file

@ -0,0 +1,73 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const rpcMock = vi.fn();
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
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),
}));
let sendReactionSignal: typeof import("./send-reactions.js").sendReactionSignal;
let removeReactionSignal: typeof import("./send-reactions.js").removeReactionSignal;
describe("sendReactionSignal", () => {
beforeAll(async () => {
({ sendReactionSignal, removeReactionSignal } = await import("./send-reactions.js"));
});
beforeEach(() => {
rpcMock.mockClear().mockResolvedValue({ timestamp: 123 });
});
it("uses recipients array and targetAuthor for uuid dms", async () => {
await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥");
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(rpcMock).toHaveBeenCalledWith("sendReaction", expect.any(Object), expect.any(Object));
expect(params.recipients).toEqual(["123e4567-e89b-12d3-a456-426614174000"]);
expect(params.groupIds).toBeUndefined();
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
expect(params).not.toHaveProperty("recipient");
expect(params).not.toHaveProperty("groupId");
});
it("uses groupIds array and maps targetAuthorUuid", async () => {
await sendReactionSignal("", 123, "✅", {
groupId: "group-id",
targetAuthorUuid: "uuid:123e4567-e89b-12d3-a456-426614174000",
});
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(params.recipients).toBeUndefined();
expect(params.groupIds).toEqual(["group-id"]);
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
});
it("defaults targetAuthor to recipient for removals", async () => {
await removeReactionSignal("+15551230000", 456, "❌");
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(params.recipients).toEqual(["+15551230000"]);
expect(params.targetAuthor).toBe("+15551230000");
expect(params.remove).toBe(true);
});
});

View file

@ -0,0 +1,199 @@
/**
* Signal reactions via signal-cli JSON-RPC API
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
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;
};
let signalConfigRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/config-runtime")>
| undefined;
async function loadSignalConfigRuntime() {
signalConfigRuntimePromise ??= import("openclaw/plugin-sdk/config-runtime");
return await signalConfigRuntimePromise;
}
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 (normalizeLowercaseStringOrEmpty(trimmed).startsWith("uuid:")) {
return trimmed.slice("uuid:".length).trim();
}
return trimmed;
}
function resolveTargetAuthorParams(params: {
targetAuthor?: string;
targetAuthorUuid?: string;
fallback?: string;
}): { targetAuthor?: string } {
const candidates = [params.targetAuthor, params.targetAuthorUuid, params.fallback];
for (const candidate of candidates) {
const raw = candidate?.trim();
if (!raw) {
continue;
}
const normalized = normalizeSignalUuid(raw);
if (normalized) {
return { targetAuthor: normalized };
}
}
return {};
}
async function sendReactionSignalCore(params: {
recipient: string;
targetTimestamp: number;
emoji: string;
remove: boolean;
opts: SignalReactionOpts;
errors: SignalReactionErrorMessages;
}): Promise<SignalReactionResult> {
const cfg = params.opts.cfg ?? (await loadSignalConfigRuntime()).loadConfig();
const accountInfo = resolveSignalAccount({
cfg,
accountId: params.opts.accountId,
});
const { baseUrl, account } = resolveSignalRpcContext(params.opts, accountInfo);
const normalizedRecipient = normalizeSignalUuid(params.recipient);
const groupId = params.opts.groupId?.trim();
if (!normalizedRecipient && !groupId) {
throw new Error(params.errors.missingRecipient);
}
if (!Number.isFinite(params.targetTimestamp) || params.targetTimestamp <= 0) {
throw new Error(params.errors.invalidTargetTimestamp);
}
const normalizedEmoji = params.emoji?.trim();
if (!normalizedEmoji) {
throw new Error(params.errors.missingEmoji);
}
const targetAuthorParams = resolveTargetAuthorParams({
targetAuthor: params.opts.targetAuthor,
targetAuthorUuid: params.opts.targetAuthorUuid,
fallback: normalizedRecipient,
});
if (groupId && !targetAuthorParams.targetAuthor) {
throw new Error(params.errors.missingTargetAuthor);
}
const requestParams: Record<string, unknown> = {
emoji: normalizedEmoji,
targetTimestamp: params.targetTimestamp,
...(params.remove ? { remove: true } : {}),
...targetAuthorParams,
};
if (normalizedRecipient) {
requestParams.recipients = [normalizedRecipient];
}
if (groupId) {
requestParams.groupIds = [groupId];
}
if (account) {
requestParams.account = account;
}
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", requestParams, {
baseUrl,
timeoutMs: params.opts.timeoutMs,
});
return {
ok: true,
timestamp: result?.timestamp,
};
}
/**
* Send a Signal reaction to a message
* @param recipient - UUID or E.164 phone number of the message author
* @param targetTimestamp - Message ID (timestamp) to react to
* @param emoji - Emoji to react with
* @param opts - Optional account/connection overrides
*/
export async function sendReactionSignal(
recipient: string,
targetTimestamp: number,
emoji: string,
opts: SignalReactionOpts = {},
): Promise<SignalReactionResult> {
return await sendReactionSignalCore({
recipient,
targetTimestamp,
emoji,
remove: false,
opts,
errors: {
missingRecipient: "Recipient or groupId is required for Signal reaction",
invalidTargetTimestamp: "Valid targetTimestamp is required for Signal reaction",
missingEmoji: "Emoji is required for Signal reaction",
missingTargetAuthor: "targetAuthor is required for group reactions",
},
});
}
/**
* Remove a Signal reaction from a message
* @param recipient - UUID or E.164 phone number of the message author
* @param targetTimestamp - Message ID (timestamp) to remove reaction from
* @param emoji - Emoji to remove
* @param opts - Optional account/connection overrides
*/
export async function removeReactionSignal(
recipient: string,
targetTimestamp: number,
emoji: string,
opts: SignalReactionOpts = {},
): Promise<SignalReactionResult> {
return await sendReactionSignalCore({
recipient,
targetTimestamp,
emoji,
remove: true,
opts,
errors: {
missingRecipient: "Recipient or groupId is required for Signal reaction removal",
invalidTargetTimestamp: "Valid targetTimestamp is required for Signal reaction removal",
missingEmoji: "Emoji is required for Signal reaction removal",
missingTargetAuthor: "targetAuthor is required for group reaction removal",
},
});
}

View file

@ -0,0 +1 @@
export { sendMessageSignal } from "./send.js";

View file

@ -0,0 +1,272 @@
import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
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;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
maxBytes?: number;
timeoutMs?: number;
textMode?: "markdown" | "plain";
textStyles?: SignalTextStyleRange[];
};
export type SignalSendResult = {
messageId: string;
timestamp?: number;
};
export type SignalRpcOpts = Pick<SignalSendOpts, "baseUrl" | "account" | "accountId" | "timeoutMs">;
export type SignalReceiptType = "read" | "viewed";
type SignalTarget =
| { type: "recipient"; recipient: string }
| { type: "group"; groupId: string }
| { type: "username"; username: string };
async function resolveSignalRpcAccountInfo(
opts: Pick<SignalSendOpts, "cfg" | "baseUrl" | "account" | "accountId">,
) {
if (opts.baseUrl?.trim() && opts.account?.trim()) {
return undefined;
}
const cfg = opts.cfg ?? loadConfig();
return resolveSignalAccount({
cfg,
accountId: opts.accountId,
});
}
function parseTarget(raw: string): SignalTarget {
let value = raw.trim();
if (!value) {
throw new Error("Signal recipient is required");
}
const lower = normalizeLowercaseStringOrEmpty(value);
if (lower.startsWith("signal:")) {
value = value.slice("signal:".length).trim();
}
const normalized = normalizeLowercaseStringOrEmpty(value);
if (normalized.startsWith("group:")) {
return { type: "group", groupId: value.slice("group:".length).trim() };
}
if (normalized.startsWith("username:")) {
return {
type: "username",
username: value.slice("username:".length).trim(),
};
}
if (normalized.startsWith("u:")) {
return { type: "username", username: value.trim() };
}
return { type: "recipient", recipient: value };
}
type SignalTargetParams = {
recipient?: string[];
groupId?: string;
username?: string[];
};
type SignalTargetAllowlist = {
recipient?: boolean;
group?: boolean;
username?: boolean;
};
function buildTargetParams(
target: SignalTarget,
allow: SignalTargetAllowlist,
): SignalTargetParams | null {
if (target.type === "recipient") {
if (!allow.recipient) {
return null;
}
return { recipient: [target.recipient] };
}
if (target.type === "group") {
if (!allow.group) {
return null;
}
return { groupId: target.groupId };
}
if (target.type === "username") {
if (!allow.username) {
return null;
}
return { username: [target.username] };
}
return null;
}
export async function sendMessageSignal(
to: string,
text: string,
opts: SignalSendOpts = {},
): Promise<SignalSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveSignalAccount({
cfg,
accountId: opts.accountId,
});
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const target = parseTarget(to);
let message = text ?? "";
let messageFromPlaceholder = false;
let textStyles: SignalTextStyleRange[] = [];
const textMode = opts.textMode ?? "markdown";
const maxBytes = (() => {
if (typeof opts.maxBytes === "number") {
return opts.maxBytes;
}
if (typeof accountInfo.config.mediaMaxMb === "number") {
return accountInfo.config.mediaMaxMb * 1024 * 1024;
}
if (typeof cfg.agents?.defaults?.mediaMaxMb === "number") {
return cfg.agents.defaults.mediaMaxMb * 1024 * 1024;
}
return 8 * 1024 * 1024;
})();
let attachments: string[] | undefined;
if (opts.mediaUrl?.trim()) {
const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes, {
mediaAccess: opts.mediaAccess,
localRoots: opts.mediaLocalRoots,
readFile: opts.mediaReadFile,
});
attachments = [resolved.path];
const kind = kindFromMime(resolved.contentType ?? undefined);
if (!message && kind) {
// Avoid sending an empty body when only attachments exist.
message = kind === "image" ? "<media:image>" : `<media:${kind}>`;
messageFromPlaceholder = true;
}
}
if (message.trim() && !messageFromPlaceholder) {
if (textMode === "plain") {
textStyles = opts.textStyles ?? [];
} else {
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "signal",
accountId: accountInfo.accountId,
});
const formatted = markdownToSignalText(message, { tableMode });
message = formatted.text;
textStyles = formatted.styles;
}
}
if (!message.trim() && (!attachments || attachments.length === 0)) {
throw new Error("Signal send requires text or media");
}
const params: Record<string, unknown> = { message };
if (textStyles.length > 0) {
params["text-style"] = textStyles.map(
(style) => `${style.start}:${style.length}:${style.style}`,
);
}
if (account) {
params.account = account;
}
if (attachments && attachments.length > 0) {
params.attachments = attachments;
}
const targetParams = buildTargetParams(target, {
recipient: true,
group: true,
username: true,
});
if (!targetParams) {
throw new Error("Signal recipient is required");
}
Object.assign(params, targetParams);
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
const timestamp = result?.timestamp;
return {
messageId: timestamp ? String(timestamp) : "unknown",
timestamp,
};
}
export async function sendTypingSignal(
to: string,
opts: SignalRpcOpts & { stop?: boolean } = {},
): Promise<boolean> {
const accountInfo = await resolveSignalRpcAccountInfo(opts);
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const targetParams = buildTargetParams(parseTarget(to), {
recipient: true,
group: true,
});
if (!targetParams) {
return false;
}
const params: Record<string, unknown> = { ...targetParams };
if (account) {
params.account = account;
}
if (opts.stop) {
params.stop = true;
}
await signalRpcRequest("sendTyping", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return true;
}
export async function sendReadReceiptSignal(
to: string,
targetTimestamp: number,
opts: SignalRpcOpts & { type?: SignalReceiptType } = {},
): Promise<boolean> {
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
return false;
}
const accountInfo = await resolveSignalRpcAccountInfo(opts);
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const targetParams = buildTargetParams(parseTarget(to), {
recipient: true,
});
if (!targetParams) {
return false;
}
const params: Record<string, unknown> = {
...targetParams,
targetTimestamp,
type: opts.type ?? "read",
};
if (account) {
params.account = account;
}
await signalRpcRequest("sendReceipt", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return true;
}

View file

@ -0,0 +1,267 @@
import {
createCliPathTextInput,
createDelegatedSetupWizardProxy,
createDelegatedTextInputShouldPrompt,
createPatchedAccountSetupAdapter,
createSetupInputPresenceValidator,
DEFAULT_ACCOUNT_ID,
mergeAllowFromEntries,
parseSetupEntriesAllowingWildcard,
patchChannelConfigForAccount,
promptParsedAllowFromForAccount,
setAccountAllowFromForChannel,
setSetupChannelEnabled,
type ChannelSetupAdapter,
type ChannelSetupWizard,
type ChannelSetupWizardTextInput,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup-runtime";
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import {
normalizeE164,
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDefaultSignalAccountId, resolveSignalAccount } from "./accounts.js";
const channel = "signal" as const;
const MIN_E164_DIGITS = 5;
const MAX_E164_DIGITS = 15;
const DIGITS_ONLY = /^\d+$/;
const INVALID_SIGNAL_ACCOUNT_ERROR =
"Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)";
export function normalizeSignalAccountInput(value: string | null | undefined): string | null {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return null;
}
const normalized = normalizeE164(trimmed);
const digits = normalized.slice(1);
if (!DIGITS_ONLY.test(digits)) {
return null;
}
if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) {
return null;
}
return `+${digits}`;
}
function isUuidLike(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
}
export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } {
return parseSetupEntriesAllowingWildcard(raw, (entry) => {
if (normalizeLowercaseStringOrEmpty(entry).startsWith("uuid:")) {
const id = entry.slice("uuid:".length).trim();
if (!id) {
return { error: "Invalid uuid entry" };
}
return { value: `uuid:${id}` };
}
if (isUuidLike(entry)) {
return { value: `uuid:${entry}` };
}
const normalized = normalizeSignalAccountInput(entry);
if (!normalized) {
return { error: `Invalid entry: ${entry}` };
}
return { value: normalized };
});
}
function buildSignalSetupPatch(input: {
signalNumber?: string;
cliPath?: string;
httpUrl?: string;
httpHost?: string;
httpPort?: string;
}) {
return {
...(input.signalNumber ? { account: input.signalNumber } : {}),
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.httpUrl ? { httpUrl: input.httpUrl } : {}),
...(input.httpHost ? { httpHost: input.httpHost } : {}),
...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}),
};
}
export async function promptSignalAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
return promptParsedAllowFromForAccount({
cfg: params.cfg,
accountId: params.accountId,
defaultAccountId: resolveDefaultSignalAccountId(params.cfg),
prompter: params.prompter,
noteTitle: "Signal allowlist",
noteLines: [
"Allowlist Signal DMs by sender id.",
"Examples:",
"- +15555550123",
"- uuid:123e4567-e89b-12d3-a456-426614174000",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/signal", "signal")}`,
],
message: "Signal allowFrom (E.164 or uuid)",
placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000",
parseEntries: parseSignalAllowFromEntries,
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [],
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
setAccountAllowFromForChannel({
cfg,
channel,
accountId,
allowFrom,
}),
});
}
export const signalDmPolicy = {
label: "Signal",
channel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
resolveConfigKeys: (cfg: OpenClawConfig, accountId?: string) =>
(accountId ?? resolveDefaultSignalAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
? {
policyKey: `channels.signal.accounts.${accountId ?? resolveDefaultSignalAccountId(cfg)}.dmPolicy`,
allowFromKey: `channels.signal.accounts.${accountId ?? resolveDefaultSignalAccountId(cfg)}.allowFrom`,
}
: {
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
},
getCurrent: (cfg: OpenClawConfig, accountId?: string) =>
resolveSignalAccount({ cfg, accountId: accountId ?? resolveDefaultSignalAccountId(cfg) }).config
.dmPolicy ?? "pairing",
setPolicy: (
cfg: OpenClawConfig,
policy: "pairing" | "allowlist" | "open" | "disabled",
accountId?: string,
) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId: accountId ?? resolveDefaultSignalAccountId(cfg),
patch:
policy === "open"
? {
dmPolicy: "open",
allowFrom: mergeAllowFromEntries(
resolveSignalAccount({
cfg,
accountId: accountId ?? resolveDefaultSignalAccountId(cfg),
}).config.allowFrom,
["*"],
),
}
: { dmPolicy: policy },
}),
promptAllowFrom: promptSignalAllowFrom,
};
function resolveSignalCliPath(params: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: Record<string, unknown>;
}) {
return (
(typeof params.credentialValues.cliPath === "string"
? params.credentialValues.cliPath
: undefined) ??
resolveSignalAccount({ cfg: params.cfg, accountId: params.accountId }).config.cliPath ??
"signal-cli"
);
}
export function createSignalCliPathTextInput(
shouldPrompt: NonNullable<ChannelSetupWizardTextInput["shouldPrompt"]>,
): ChannelSetupWizardTextInput {
return createCliPathTextInput({
inputKey: "cliPath",
message: "signal-cli path",
resolvePath: ({ cfg, accountId, credentialValues }) =>
resolveSignalCliPath({ cfg, accountId, credentialValues }),
shouldPrompt,
helpTitle: "Signal",
helpLines: [
"signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.",
],
});
}
export const signalNumberTextInput: ChannelSetupWizardTextInput = {
inputKey: "signalNumber",
message: "Signal bot number (E.164)",
currentValue: ({ cfg, accountId }) =>
normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ??
undefined,
keepPrompt: (value) => `Signal account set (${value}). Keep it?`,
validate: ({ value }) =>
normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR,
normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value,
};
export const signalCompletionNote = {
title: "Signal next steps",
lines: [
'Link device with: signal-cli link -n "OpenClaw"',
"Scan QR in Signal -> Linked Devices",
`Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`,
`Docs: ${formatDocsLink("/signal", "signal")}`,
],
};
export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
validateInput: createSetupInputPresenceValidator({
validate: ({ input }) => {
if (
!input.signalNumber &&
!input.httpUrl &&
!input.httpHost &&
!input.httpPort &&
!input.cliPath
) {
return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path.";
}
return null;
},
}),
buildPatch: (input) => buildSignalSetupPatch(input),
});
export function createSignalSetupWizardProxy(loadWizard: () => Promise<ChannelSetupWizard>) {
return createDelegatedSetupWizardProxy({
channel,
loadWizard,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "signal-cli found",
unconfiguredHint: "signal-cli missing",
configuredScore: 1,
unconfiguredScore: 0,
},
delegatePrepare: true,
credentials: [],
textInputs: [
createSignalCliPathTextInput(
createDelegatedTextInputShouldPrompt({
loadWizard,
inputKey: "cliPath",
}),
),
signalNumberTextInput,
],
completionNote: signalCompletionNote,
dmPolicy: signalDmPolicy,
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
});
}

View file

@ -0,0 +1,91 @@
import {
createDetectedBinaryStatus,
setSetupChannelEnabled,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js";
import { installSignalCli } from "./install-signal-cli.js";
import {
createSignalCliPathTextInput,
normalizeSignalAccountInput,
parseSignalAllowFromEntries,
signalCompletionNote,
signalDmPolicy,
signalNumberTextInput,
signalSetupAdapter,
} from "./setup-core.js";
const channel = "signal" as const;
export const signalSetupWizard: ChannelSetupWizard = {
channel,
status: createDetectedBinaryStatus({
channelLabel: "Signal",
binaryLabel: "signal-cli",
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "signal-cli found",
unconfiguredHint: "signal-cli missing",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: ({ cfg, accountId }) =>
accountId
? resolveSignalAccount({ cfg, accountId }).configured
: listSignalAccountIds(cfg).some(
(resolvedAccountId) =>
resolveSignalAccount({ cfg, accountId: resolvedAccountId }).configured,
),
resolveBinaryPath: ({ cfg, accountId }) =>
resolveSignalAccount({ cfg, accountId }).config.cliPath ?? "signal-cli",
detectBinary,
}),
prepare: async ({ cfg, accountId, credentialValues, runtime, prompter, options }) => {
if (!options?.allowSignalInstall) {
return undefined;
}
const currentCliPath =
(typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ??
resolveSignalAccount({ cfg, accountId }).config.cliPath ??
"signal-cli";
const cliDetected = await detectBinary(currentCliPath);
const wantsInstall = await prompter.confirm({
message: cliDetected
? "signal-cli detected. Reinstall/update now?"
: "signal-cli not found. Install now?",
initialValue: !cliDetected,
});
if (!wantsInstall) {
return undefined;
}
try {
const result = await installSignalCli(runtime);
if (result.ok && result.cliPath) {
await prompter.note(`Installed signal-cli at ${result.cliPath}`, "Signal");
return {
credentialValues: {
cliPath: result.cliPath,
},
};
}
if (!result.ok) {
await prompter.note(result.error ?? "signal-cli install failed.", "Signal");
}
} catch (error) {
await prompter.note(`signal-cli install failed: ${String(error)}`, "Signal");
}
return undefined;
},
credentials: [],
textInputs: [
createSignalCliPathTextInput(async ({ currentValue }) => {
return !(await detectBinary(currentValue ?? "signal-cli"));
}),
signalNumberTextInput,
],
completionNote: signalCompletionNote,
dmPolicy: signalDmPolicy,
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter };

View file

@ -0,0 +1,129 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
import { createChannelPluginBase, getChatChannelMeta } from "openclaw/plugin-sdk/core";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
normalizeE164,
normalizeStringifiedOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
type ResolvedSignalAccount,
} from "./accounts.js";
import { SignalChannelConfigSchema } from "./config-schema.js";
import { createSignalSetupWizardProxy } from "./setup-core.js";
export const SIGNAL_CHANNEL = "signal" as const;
async function loadSignalChannelRuntime() {
return await import("./channel.runtime.js");
}
export const signalSetupWizard = createSignalSetupWizardProxy(
async () => (await loadSignalChannelRuntime()).signalSetupWizard,
);
export const signalConfigAdapter = createScopedChannelConfigAdapter<ResolvedSignalAccount>({
sectionKey: SIGNAL_CHANNEL,
listAccountIds: (cfg) => listSignalAccountIds(cfg),
resolveAccount: adaptScopedAccountAccessor((params) => resolveSignalAccount(params)),
defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg),
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => normalizeStringifiedOptionalString(entry))
.filter((entry): entry is string => Boolean(entry))
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
.filter(Boolean),
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
});
export const signalSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedSignalAccount>({
channelKey: SIGNAL_CHANNEL,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupPolicy: (account) => account.config.groupPolicy,
surface: "Signal groups",
openScope: "any member",
groupPolicyPath: "channels.signal.groupPolicy",
groupAllowFromPath: "channels.signal.groupAllowFrom",
mentionGated: false,
policyPathSuffix: "dmPolicy",
normalizeDmEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
});
export function createSignalPluginBase(params: {
setupWizard?: NonNullable<ChannelPlugin<ResolvedSignalAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedSignalAccount>["setup"]>;
}): Pick<
ChannelPlugin<ResolvedSignalAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "streaming"
| "reload"
| "configSchema"
| "config"
| "security"
| "setup"
| "messaging"
> {
const base = createChannelPluginBase({
id: SIGNAL_CHANNEL,
meta: {
...getChatChannelMeta(SIGNAL_CHANNEL),
},
setupWizard: params.setupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.signal"] },
configSchema: SignalChannelConfigSchema,
config: {
...signalConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
},
}),
},
security: signalSecurityAdapter,
setup: params.setup,
});
return {
...base,
messaging: {
defaultMarkdownTableMode: "bullets",
},
} as Pick<
ChannelPlugin<ResolvedSignalAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "streaming"
| "reload"
| "configSchema"
| "config"
| "security"
| "setup"
| "messaging"
>;
}

View file

@ -0,0 +1,87 @@
import {
computeBackoff,
logVerbose,
shouldLogVerbose,
sleepWithAbort,
type BackoffPolicy,
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { type SignalSseEvent, streamSignalEvents } from "./client.js";
const DEFAULT_RECONNECT_POLICY: BackoffPolicy = {
initialMs: 1_000,
maxMs: 10_000,
factor: 2,
jitter: 0.2,
};
type RunSignalSseLoopParams = {
baseUrl: string;
account?: string;
abortSignal?: AbortSignal;
runtime: RuntimeEnv;
onEvent: (event: SignalSseEvent) => void;
policy?: Partial<BackoffPolicy>;
};
export async function runSignalSseLoop({
baseUrl,
account,
abortSignal,
runtime,
onEvent,
policy,
}: RunSignalSseLoopParams) {
const reconnectPolicy = {
...DEFAULT_RECONNECT_POLICY,
...policy,
};
let reconnectAttempts = 0;
const logReconnectVerbose = (message: string) => {
if (!shouldLogVerbose()) {
return;
}
logVerbose(message);
};
for (;;) {
if (abortSignal?.aborted) {
break;
}
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;
}
}
}
}

View file

@ -0,0 +1,13 @@
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);
}

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}