Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
13
extensions/signal/README.md
Normal file
13
extensions/signal/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Signal OpenClaw channel
|
||||
|
||||
Official OpenClaw channel plugin for Signal.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
openclaw plugins install @openclaw/signal
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
See `docs/channels/signal.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/channels/signal`.
|
||||
66
extensions/signal/api.ts
Normal file
66
extensions/signal/api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Signal API module exposes the plugin public contract.
|
||||
export { signalPlugin } from "./src/channel.js";
|
||||
export { signalSetupPlugin } from "./src/channel.setup.js";
|
||||
export {
|
||||
listEnabledSignalAccounts,
|
||||
listSignalAccountIds,
|
||||
resolveDefaultSignalAccountId,
|
||||
type ResolvedSignalAccount,
|
||||
resolveSignalAccount,
|
||||
} from "./src/accounts.js";
|
||||
export {
|
||||
markdownToSignalText,
|
||||
markdownToSignalTextChunks,
|
||||
type SignalFormattedText,
|
||||
type SignalTextStyleRange,
|
||||
} from "./src/format.js";
|
||||
export {
|
||||
formatSignalPairingIdLine,
|
||||
formatSignalSenderDisplay,
|
||||
formatSignalSenderId,
|
||||
isSignalSenderAllowed,
|
||||
looksLikeUuid,
|
||||
normalizeSignalAllowRecipient,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
type SignalSender,
|
||||
} from "./src/identity.js";
|
||||
export {
|
||||
extractSignalCliArchive,
|
||||
installSignalCli,
|
||||
looksLikeArchive,
|
||||
type NamedAsset,
|
||||
pickAsset,
|
||||
type ReleaseAsset,
|
||||
type SignalInstallResult,
|
||||
} from "./src/install-signal-cli.js";
|
||||
export { signalMessageActions } from "./src/message-actions.js";
|
||||
export { type MonitorSignalOpts, monitorSignalProvider } from "./src/monitor.js";
|
||||
export { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./src/normalize.js";
|
||||
export {
|
||||
type ResolvedSignalOutboundTarget,
|
||||
resolveSignalOutboundTarget,
|
||||
} from "./src/outbound-session.js";
|
||||
export { probeSignal, type SignalProbe } from "./src/probe.js";
|
||||
export {
|
||||
type ResolvedSignalReactionLevel,
|
||||
resolveSignalReactionLevel,
|
||||
type SignalReactionLevel,
|
||||
} from "./src/reaction-level.js";
|
||||
export {
|
||||
removeReactionSignal,
|
||||
sendReactionSignal,
|
||||
type SignalReactionOpts,
|
||||
type SignalReactionResult,
|
||||
} from "./src/send-reactions.js";
|
||||
export {
|
||||
sendMessageSignal,
|
||||
sendReadReceiptSignal,
|
||||
sendTypingSignal,
|
||||
type SignalReceiptType,
|
||||
type SignalRpcOpts,
|
||||
type SignalSendOpts,
|
||||
type SignalSendResult,
|
||||
} from "./src/send.js";
|
||||
export { normalizeSignalAccountInput } from "./src/setup-core.js";
|
||||
2
extensions/signal/channel-config-api.ts
Normal file
2
extensions/signal/channel-config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Signal API module exposes the plugin public contract.
|
||||
export { SignalChannelConfigSchema } from "./src/config-schema.js";
|
||||
17
extensions/signal/channel-entry.ts
Normal file
17
extensions/signal/channel-entry.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Signal plugin module implements channel entry behavior.
|
||||
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",
|
||||
},
|
||||
});
|
||||
3
extensions/signal/channel-plugin-api.ts
Normal file
3
extensions/signal/channel-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag setup-only Signal surfaces into lightweight channel plugin loads.
|
||||
export { signalPlugin } from "./src/channel.js";
|
||||
5
extensions/signal/config-api.ts
Normal file
5
extensions/signal/config-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Signal API module exposes the plugin public contract.
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
SignalConfigSchema,
|
||||
} from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
4
extensions/signal/contract-api.ts
Normal file
4
extensions/signal/contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Signal API module exposes the plugin public contract.
|
||||
export * from "./src/install-signal-cli.js";
|
||||
export * from "./src/normalize.js";
|
||||
export { isSignalSenderAllowed, type SignalSender } from "./src/identity.js";
|
||||
17
extensions/signal/index.ts
Normal file
17
extensions/signal/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Signal plugin entrypoint registers its OpenClaw integration.
|
||||
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: "./channel-plugin-api.js",
|
||||
exportName: "signalPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setSignalRuntime",
|
||||
},
|
||||
});
|
||||
36
extensions/signal/npm-shrinkwrap.json
generated
Normal file
36
extensions/signal/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"ws": "8.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
extensions/signal/openclaw.plugin.json
Normal file
13
extensions/signal/openclaw.plugin.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "signal",
|
||||
"icon": "https://cdn.simpleicons.org/signal",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["signal"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
65
extensions/signal/package.json
Normal file
65
extensions/signal/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Signal channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"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,
|
||||
"cliAddOptions": [
|
||||
{
|
||||
"flags": "--signal-number <e164>",
|
||||
"description": "Signal account number (E.164)"
|
||||
},
|
||||
{
|
||||
"flags": "--http-host <host>",
|
||||
"description": "Signal HTTP daemon host"
|
||||
},
|
||||
{
|
||||
"flags": "--http-port <port>",
|
||||
"description": "Signal HTTP daemon port"
|
||||
}
|
||||
]
|
||||
},
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/signal",
|
||||
"npmSpec": "@openclaw/signal",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.9",
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
}
|
||||
}
|
||||
7
extensions/signal/reaction-runtime-api.ts
Normal file
7
extensions/signal/reaction-runtime-api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Signal API module exposes the plugin public contract.
|
||||
export {
|
||||
removeReactionSignal,
|
||||
sendReactionSignal,
|
||||
type SignalReactionOpts,
|
||||
type SignalReactionResult,
|
||||
} from "./src/send-reactions.js";
|
||||
49
extensions/signal/runtime-api.ts
Normal file
49
extensions/signal/runtime-api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Signal API module exposes the plugin public contract.
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
buildBaseAccountStatusSnapshot,
|
||||
buildBaseChannelStatusSummary,
|
||||
buildChannelConfigSchema,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
chunkText,
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
detectBinary,
|
||||
emptyPluginConfigSchema,
|
||||
formatCliCommand,
|
||||
formatDocsLink,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
installSignalCli,
|
||||
listEnabledSignalAccounts,
|
||||
listSignalAccountIds,
|
||||
looksLikeSignalTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
monitorSignalProvider,
|
||||
normalizeAccountId,
|
||||
normalizeE164,
|
||||
normalizeSignalMessagingTarget,
|
||||
type OpenClawConfig,
|
||||
type OpenClawPluginApi,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
type PluginRuntime,
|
||||
probeSignal,
|
||||
removeReactionSignal,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDefaultSignalAccountId,
|
||||
type ResolvedSignalAccount,
|
||||
resolveSignalAccount,
|
||||
resolveSignalReactionLevel,
|
||||
sendMessageSignal,
|
||||
sendReactionSignal,
|
||||
setAccountEnabledInConfigSection,
|
||||
type SignalAccountConfig,
|
||||
SignalConfigSchema,
|
||||
signalMessageActions,
|
||||
} from "./src/runtime-api.js";
|
||||
export { setSignalRuntime } from "./src/runtime.js";
|
||||
4
extensions/signal/secret-contract-api.ts
Normal file
4
extensions/signal/secret-contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Signal does not expose secret-contract surfaces.
|
||||
export const secretTargetRegistryEntries: readonly [] = [];
|
||||
|
||||
export function collectRuntimeConfigAssignments(): void {}
|
||||
10
extensions/signal/setup-entry.ts
Normal file
10
extensions/signal/setup-entry.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Signal plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
exportName: "signalSetupPlugin",
|
||||
},
|
||||
});
|
||||
7
extensions/signal/src/account-types.ts
Normal file
7
extensions/signal/src/account-types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Signal plugin module implements account types behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
export type SignalAccountConfig = Omit<
|
||||
Exclude<NonNullable<OpenClawConfig["channels"]>["signal"], undefined>,
|
||||
"accounts"
|
||||
>;
|
||||
51
extensions/signal/src/accounts.test.ts
Normal file
51
extensions/signal/src/accounts.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Signal tests cover accounts plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("resolveSignalAccount", () => {
|
||||
it("preserves top-level default account when named accounts are configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15555550123",
|
||||
accounts: {
|
||||
work: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
expect(listSignalAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultSignalAccountId(cfg)).toBe("default");
|
||||
expect(resolveSignalAccount({ cfg }).config.account).toBe("+15555550123");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
76
extensions/signal/src/accounts.ts
Normal file
76
extensions/signal/src/accounts.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Signal plugin module implements accounts behavior.
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
normalizeAccountId,
|
||||
resolveMergedAccountConfig,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-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", {
|
||||
implicitDefaultAccount: {
|
||||
channelKeys: ["account"],
|
||||
},
|
||||
});
|
||||
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,
|
||||
nestedObjectKeys: ["aliases"],
|
||||
});
|
||||
}
|
||||
|
||||
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.configPath) ||
|
||||
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);
|
||||
}
|
||||
264
extensions/signal/src/aliases.test.ts
Normal file
264
extensions/signal/src/aliases.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
// Signal tests cover alias target resolution behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listSignalAliasDirectoryEntries,
|
||||
resolveSignalAliasTarget,
|
||||
resolveSignalTarget,
|
||||
} from "./aliases.js";
|
||||
|
||||
describe("resolveSignalAliasTarget", () => {
|
||||
it("resolves top-level DM aliases to canonical targets", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
home: "+15551234567",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveSignalAliasTarget({ cfg, input: "signal:home" })).toEqual({
|
||||
kind: "user",
|
||||
to: "+15551234567",
|
||||
alias: "home",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves account aliases after merging top-level aliases", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
home: "+15551234567",
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
aliases: {
|
||||
ops: "signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveSignalAliasTarget({ cfg, accountId: "work", input: "ops" })).toEqual({
|
||||
kind: "group",
|
||||
to: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
alias: "ops",
|
||||
});
|
||||
expect(resolveSignalAliasTarget({ cfg, accountId: "work", input: "home" })?.to).toBe(
|
||||
"+15551234567",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects recursive aliases before delivery", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
home: "signal:me",
|
||||
me: "home",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(() => resolveSignalAliasTarget({ cfg, input: "home" })).toThrow(
|
||||
'Signal alias "home" resolves recursively through "home".',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects aliases whose final value is not a Signal target", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
jane: "not a target",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(() => resolveSignalAliasTarget({ cfg, input: "jane" })).toThrow(
|
||||
'Signal alias "jane" must point to an E.164 number, uuid:<id>, username:<name>, or group:<id>.',
|
||||
);
|
||||
});
|
||||
|
||||
it("treats target-looking alias values as terminal targets before alias chaining", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
home: "+15551230000",
|
||||
"+15551230000": "+15559990000",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveSignalAliasTarget({ cfg, input: "home" })).toEqual({
|
||||
kind: "user",
|
||||
to: "+15551230000",
|
||||
alias: "home",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves own prototype-shaped aliases without inheriting prototype keys", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
constructor: "+15551234567",
|
||||
toString: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveSignalAliasTarget({ cfg, input: "constructor" })).toEqual({
|
||||
kind: "user",
|
||||
to: "+15551234567",
|
||||
alias: "constructor",
|
||||
});
|
||||
expect(resolveSignalAliasTarget({ cfg, input: "toString" })).toEqual({
|
||||
kind: "group",
|
||||
to: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
alias: "tostring",
|
||||
});
|
||||
|
||||
const ordinaryCfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
me: "+15551234567",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveSignalAliasTarget({ cfg: ordinaryCfg, input: "constructor" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSignalTarget", () => {
|
||||
it("resolves aliases and raw targets through the same canonical parser", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
me: "uuid:123E4567-E89B-12D3-A456-426614174000",
|
||||
"+15551230000": "+15559990000",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveSignalTarget({ cfg, input: "signal:me" })).toEqual({
|
||||
kind: "user",
|
||||
to: "123e4567-e89b-12d3-a456-426614174000",
|
||||
source: "alias",
|
||||
alias: "me",
|
||||
});
|
||||
expect(
|
||||
resolveSignalTarget({ cfg, input: "uuid:123E4567-E89B-12D3-A456-426614174000" }),
|
||||
).toEqual({
|
||||
kind: "user",
|
||||
to: "123e4567-e89b-12d3-a456-426614174000",
|
||||
source: "raw",
|
||||
});
|
||||
expect(resolveSignalTarget({ cfg, input: "+15551230000" })).toEqual({
|
||||
kind: "user",
|
||||
to: "+15551230000",
|
||||
source: "raw",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSignalAliasDirectoryEntries", () => {
|
||||
it("lists alias-backed peers and groups with alias display names", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
me: "+15551234567",
|
||||
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(listSignalAliasDirectoryEntries({ cfg, kind: "user" })).toEqual([
|
||||
{ kind: "user", id: "+15551234567", name: "me" },
|
||||
]);
|
||||
expect(listSignalAliasDirectoryEntries({ cfg, kind: "user", query: "1555" })).toEqual([
|
||||
{ kind: "user", id: "+15551234567", name: "me" },
|
||||
]);
|
||||
expect(listSignalAliasDirectoryEntries({ cfg, kind: "group" })).toEqual([
|
||||
{
|
||||
kind: "group",
|
||||
id: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
name: "ops",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists aliases that resolve through another alias", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
me: "+15551234567",
|
||||
home: "signal:me",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(listSignalAliasDirectoryEntries({ cfg, kind: "user" })).toEqual([
|
||||
{ kind: "user", id: "+15551234567", name: "me" },
|
||||
{ kind: "user", id: "+15551234567", name: "home" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let fuzzy peer matches shadow exact group aliases", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
"ops-dm": "+15551234567",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(listSignalAliasDirectoryEntries({ cfg, kind: "user", query: "ops" })).toEqual([]);
|
||||
expect(listSignalAliasDirectoryEntries({ cfg, kind: "group", query: "ops" })).toEqual([
|
||||
{
|
||||
kind: "group",
|
||||
id: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
name: "ops",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails invalid exact aliases instead of falling through to fuzzy matches", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
ops: "not-a-signal-target",
|
||||
"ops-dm": "+15551234567",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(() => listSignalAliasDirectoryEntries({ cfg, kind: "user", query: "ops" })).toThrow(
|
||||
'Signal alias "ops" must point to an E.164 number, uuid:<id>, username:<name>, or group:<id>.',
|
||||
);
|
||||
});
|
||||
});
|
||||
192
extensions/signal/src/aliases.ts
Normal file
192
extensions/signal/src/aliases.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Signal alias helpers keep OpenClaw-side names inside the Signal plugin boundary.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/core";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
|
||||
export type SignalResolvedTargetKind = "user" | "group";
|
||||
|
||||
export type ResolvedSignalAliasTarget = {
|
||||
to: string;
|
||||
kind: SignalResolvedTargetKind;
|
||||
alias: string;
|
||||
};
|
||||
|
||||
export type ResolvedSignalTarget =
|
||||
| (ResolvedSignalAliasTarget & { source: "alias" })
|
||||
| {
|
||||
to: string;
|
||||
kind: SignalResolvedTargetKind;
|
||||
source: "raw";
|
||||
};
|
||||
|
||||
function normalizeAliasKey(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutSignal = /^signal:/i.test(trimmed)
|
||||
? trimmed.slice("signal:".length).trim()
|
||||
: trimmed;
|
||||
const normalized = normalizeLowercaseStringOrEmpty(withoutSignal);
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function resolveAliasMap(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): Map<string, string> {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const aliases = new Map<string, string>();
|
||||
for (const [rawKey, rawValue] of Object.entries(account.config.aliases ?? {})) {
|
||||
const key = normalizeAliasKey(rawKey);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
aliases.set(key, rawValue);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function resolveTargetKind(target: string): SignalResolvedTargetKind {
|
||||
return normalizeLowercaseStringOrEmpty(target).startsWith("group:") ? "group" : "user";
|
||||
}
|
||||
|
||||
function resolveRawSignalTarget(
|
||||
input: string,
|
||||
): { to: string; kind: SignalResolvedTargetKind } | null {
|
||||
const normalized = normalizeSignalMessagingTarget(input);
|
||||
if (!normalized || !looksLikeSignalTargetId(input, normalized)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: normalized,
|
||||
kind: resolveTargetKind(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSignalAliasTargetFromMap(params: {
|
||||
aliases: ReadonlyMap<string, string>;
|
||||
input: string;
|
||||
}): ResolvedSignalAliasTarget | null {
|
||||
const initialAlias = normalizeAliasKey(params.input);
|
||||
if (!initialAlias || !params.aliases.has(initialAlias)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
let alias = initialAlias;
|
||||
for (;;) {
|
||||
if (visited.has(alias)) {
|
||||
throw new Error(`Signal alias "${initialAlias}" resolves recursively through "${alias}".`);
|
||||
}
|
||||
visited.add(alias);
|
||||
|
||||
const rawValue = params.aliases.get(alias);
|
||||
if (typeof rawValue !== "string" || !rawValue.trim()) {
|
||||
throw new Error(`Signal alias "${alias}" must point to a non-empty Signal target.`);
|
||||
}
|
||||
|
||||
const rawTarget = resolveRawSignalTarget(rawValue);
|
||||
if (rawTarget) {
|
||||
return {
|
||||
...rawTarget,
|
||||
alias: initialAlias,
|
||||
};
|
||||
}
|
||||
|
||||
const nextAlias = normalizeAliasKey(rawValue);
|
||||
if (nextAlias && params.aliases.has(nextAlias)) {
|
||||
alias = nextAlias;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Signal alias "${initialAlias}" must point to an E.164 number, uuid:<id>, username:<name>, or group:<id>.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSignalAliasTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
input: string;
|
||||
}): ResolvedSignalAliasTarget | null {
|
||||
const aliases = resolveAliasMap(params);
|
||||
return resolveSignalAliasTargetFromMap({
|
||||
aliases,
|
||||
input: params.input,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSignalTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
input: string;
|
||||
}): ResolvedSignalTarget | null {
|
||||
const rawTarget = resolveRawSignalTarget(params.input);
|
||||
if (rawTarget) {
|
||||
return {
|
||||
...rawTarget,
|
||||
source: "raw",
|
||||
};
|
||||
}
|
||||
const aliasTarget = resolveSignalAliasTarget(params);
|
||||
if (aliasTarget) {
|
||||
return { ...aliasTarget, source: "alias" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listSignalAliasDirectoryEntries(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
kind: SignalResolvedTargetKind;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}): ChannelDirectoryEntry[] {
|
||||
const aliases = resolveAliasMap(params);
|
||||
const query = normalizeLowercaseStringOrEmpty(params.query);
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : undefined;
|
||||
const exactAlias = params.query ? normalizeAliasKey(params.query) : undefined;
|
||||
if (exactAlias && aliases.has(exactAlias)) {
|
||||
const target = resolveSignalAliasTargetFromMap({ aliases, input: exactAlias });
|
||||
if (target?.kind === params.kind) {
|
||||
return [{ kind: params.kind, id: target.to, name: target.alias }];
|
||||
}
|
||||
if (target) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const entries: ChannelDirectoryEntry[] = [];
|
||||
for (const alias of aliases.keys()) {
|
||||
let target: ResolvedSignalAliasTarget | null;
|
||||
try {
|
||||
target = resolveSignalAliasTargetFromMap({ aliases, input: alias });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
if (target.kind !== params.kind) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
query &&
|
||||
!alias.includes(query) &&
|
||||
!normalizeLowercaseStringOrEmpty(target.to).includes(query)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ kind: params.kind, id: target.to, name: alias });
|
||||
if (typeof limit === "number" && entries.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
73
extensions/signal/src/approval-auth.test.ts
Normal file
73
extensions/signal/src/approval-auth.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Signal tests cover approval auth plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSignalApprovalApprovers, 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 });
|
||||
});
|
||||
|
||||
it("authorizes defaultTo aliases with the canonical Signal target", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: [],
|
||||
defaultTo: "signal:me",
|
||||
aliases: {
|
||||
me: "+15551230000",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
signalApprovalAuth.authorizeActorAction({
|
||||
cfg,
|
||||
senderId: "+15551230000",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
|
||||
it("ignores recursive defaultTo aliases when resolving approvers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: [],
|
||||
defaultTo: "signal:home",
|
||||
aliases: {
|
||||
home: "signal:me",
|
||||
me: "home",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(getSignalApprovalApprovers({ cfg })).toEqual([]);
|
||||
});
|
||||
});
|
||||
53
extensions/signal/src/approval-auth.ts
Normal file
53
extensions/signal/src/approval-auth.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Signal plugin module implements approval auth behavior.
|
||||
import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { resolveSignalTarget } from "./aliases.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 function getSignalApprovalApprovers(params: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const account = resolveSignalAccount(params).config;
|
||||
let defaultTo = account.defaultTo;
|
||||
if (typeof account.defaultTo === "string") {
|
||||
try {
|
||||
defaultTo =
|
||||
resolveSignalTarget({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
input: account.defaultTo,
|
||||
})?.to ?? account.defaultTo;
|
||||
} catch {
|
||||
defaultTo = account.defaultTo;
|
||||
}
|
||||
}
|
||||
return resolveApprovalApprovers({
|
||||
allowFrom: account.allowFrom,
|
||||
defaultTo,
|
||||
normalizeApprover: normalizeSignalApproverId,
|
||||
});
|
||||
}
|
||||
|
||||
export const signalApprovalAuth = createResolvedApproverActionAuthAdapter({
|
||||
channelLabel: "Signal",
|
||||
resolveApprovers: getSignalApprovalApprovers,
|
||||
normalizeSenderId: (value) => normalizeSignalApproverId(value),
|
||||
});
|
||||
220
extensions/signal/src/approval-handler.runtime.test.ts
Normal file
220
extensions/signal/src/approval-handler.runtime.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// Signal tests cover approval handler plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
sendTypingSignal: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendTypingSignal: sendMocks.sendTypingSignal,
|
||||
sendMessageSignal: sendMocks.sendMessageSignal,
|
||||
}));
|
||||
|
||||
const { signalApprovalNativeRuntime } = await import("./approval-handler.runtime.js");
|
||||
|
||||
function buildPendingContent(params: {
|
||||
manualText: string;
|
||||
reactionText?: string;
|
||||
allowedDecisions?: readonly ("allow-once" | "allow-always" | "deny")[];
|
||||
}) {
|
||||
const allowedDecisions = params.allowedDecisions ?? ["allow-once"];
|
||||
return {
|
||||
manualFallbackPayload: { text: params.manualText },
|
||||
reactionPayload: {
|
||||
text: params.reactionText ?? params.manualText,
|
||||
allowedDecisions,
|
||||
reactionBindings: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Signal approval native runtime", () => {
|
||||
beforeEach(() => {
|
||||
sendMocks.sendTypingSignal.mockReset().mockResolvedValue(true);
|
||||
sendMocks.sendMessageSignal.mockReset().mockResolvedValue({
|
||||
messageId: "1700000000000",
|
||||
timestamp: 1700000000000,
|
||||
receipt: { parts: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the live Signal RPC context when delivering approval prompts", async () => {
|
||||
const prepared = await signalApprovalNativeRuntime.transport.prepareTarget({
|
||||
plannedTarget: { target: { to: "+15551230000" } },
|
||||
accountId: "default",
|
||||
context: { baseUrl: "http://127.0.0.1:18080", account: "+15550001111" },
|
||||
} as never);
|
||||
|
||||
expect(prepared?.target).toMatchObject({
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
baseUrl: "http://127.0.0.1:18080",
|
||||
account: "+15550001111",
|
||||
});
|
||||
|
||||
await signalApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg: {},
|
||||
preparedTarget: prepared!.target,
|
||||
pendingPayload: buildPendingContent({ manualText: "approval" }),
|
||||
} as never);
|
||||
|
||||
expect(sendMocks.sendTypingSignal).toHaveBeenCalledWith("+15551230000", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
baseUrl: "http://127.0.0.1:18080",
|
||||
account: "+15550001111",
|
||||
});
|
||||
expect(sendMocks.sendMessageSignal).toHaveBeenCalledWith("+15551230000", "approval", {
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
baseUrl: "http://127.0.0.1:18080",
|
||||
account: "+15550001111",
|
||||
textMode: "plain",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves aliases before delivering native approval prompts", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
aliases: {
|
||||
me: "+15551230000",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const prepared = await signalApprovalNativeRuntime.transport.prepareTarget({
|
||||
cfg,
|
||||
plannedTarget: { target: { to: "signal:me" } },
|
||||
accountId: "default",
|
||||
context: { baseUrl: "http://127.0.0.1:18080", account: "+15550001111" },
|
||||
} as never);
|
||||
|
||||
expect(prepared?.target).toMatchObject({
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
baseUrl: "http://127.0.0.1:18080",
|
||||
account: "+15550001111",
|
||||
});
|
||||
|
||||
const entry = await signalApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg,
|
||||
preparedTarget: prepared!.target,
|
||||
pendingPayload: buildPendingContent({ manualText: "approval" }),
|
||||
} as never);
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
to: "+15551230000",
|
||||
conversationKey: "+15551230000",
|
||||
});
|
||||
expect(sendMocks.sendTypingSignal).toHaveBeenCalledWith("+15551230000", {
|
||||
cfg,
|
||||
accountId: "default",
|
||||
baseUrl: "http://127.0.0.1:18080",
|
||||
account: "+15550001111",
|
||||
});
|
||||
expect(sendMocks.sendMessageSignal).toHaveBeenCalledWith("+15551230000", "approval", {
|
||||
cfg,
|
||||
accountId: "default",
|
||||
baseUrl: "http://127.0.0.1:18080",
|
||||
account: "+15550001111",
|
||||
textMode: "plain",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves default-account aliases before delivering native approval prompts", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
aliases: {
|
||||
ops: "+15551230000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const prepared = await signalApprovalNativeRuntime.transport.prepareTarget({
|
||||
cfg,
|
||||
plannedTarget: { target: { to: "signal:ops" } },
|
||||
context: { baseUrl: "http://127.0.0.1:18080", account: "+15550001111" },
|
||||
} as never);
|
||||
|
||||
expect(prepared?.target).toMatchObject({
|
||||
to: "+15551230000",
|
||||
accountId: "work",
|
||||
baseUrl: "http://127.0.0.1:18080",
|
||||
account: "+15550001111",
|
||||
});
|
||||
|
||||
await signalApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg,
|
||||
preparedTarget: prepared!.target,
|
||||
pendingPayload: buildPendingContent({ manualText: "approval" }),
|
||||
} as never);
|
||||
|
||||
expect(sendMocks.sendMessageSignal).toHaveBeenCalledWith("+15551230000", "approval", {
|
||||
cfg,
|
||||
accountId: "work",
|
||||
baseUrl: "http://127.0.0.1:18080",
|
||||
account: "+15550001111",
|
||||
textMode: "plain",
|
||||
});
|
||||
});
|
||||
|
||||
it("only renders reaction hints when the Signal target author can be bound", async () => {
|
||||
const cfg = { channels: { signal: { allowFrom: ["+15551230000"] } } };
|
||||
const unbound = await signalApprovalNativeRuntime.transport.prepareTarget({
|
||||
plannedTarget: { target: { to: "+15551230000" } },
|
||||
accountId: "default",
|
||||
context: { baseUrl: "http://127.0.0.1:18080" },
|
||||
} as never);
|
||||
|
||||
await signalApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg,
|
||||
preparedTarget: unbound!.target,
|
||||
pendingPayload: buildPendingContent({
|
||||
manualText:
|
||||
"Exec approval required\nID: exec-1\n\nReply with: /approve exec-1 allow-once|deny",
|
||||
reactionText:
|
||||
"Exec approval required\nID: exec-1\n\nReact with:\n\n👍 Allow Once\n👎 Deny\n\nReply with: /approve exec-1 allow-once|deny",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(sendMocks.sendMessageSignal).toHaveBeenLastCalledWith(
|
||||
"+15551230000",
|
||||
expect.not.stringContaining("React with:"),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
const bound = await signalApprovalNativeRuntime.transport.prepareTarget({
|
||||
plannedTarget: { target: { to: "+15551230000" } },
|
||||
accountId: "default",
|
||||
context: { baseUrl: "http://127.0.0.1:18080", account: "+15550001111" },
|
||||
} as never);
|
||||
|
||||
await signalApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg,
|
||||
preparedTarget: bound!.target,
|
||||
pendingPayload: buildPendingContent({
|
||||
manualText:
|
||||
"Exec approval required\nID: exec-1\n\nReply with: /approve exec-1 allow-once|deny",
|
||||
reactionText:
|
||||
"Exec approval required\nID: exec-1\n\nReact with:\n\n👍 Allow Once\n👎 Deny\n\nReply with: /approve exec-1 allow-once|deny",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(sendMocks.sendMessageSignal).toHaveBeenLastCalledWith(
|
||||
"+15551230000",
|
||||
expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
259
extensions/signal/src/approval-handler.runtime.ts
Normal file
259
extensions/signal/src/approval-handler.runtime.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
// Signal plugin module implements approval handler behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
buildChannelApprovalExpiredText,
|
||||
buildChannelApprovalResolvedText,
|
||||
createChannelApprovalNativeRuntimeAdapter,
|
||||
type PendingApprovalView,
|
||||
resolvePreparedApprovalAccountId,
|
||||
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import {
|
||||
buildApprovalReactionPendingContent,
|
||||
type ApprovalReactionPendingContent,
|
||||
} from "openclaw/plugin-sdk/approval-reaction-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveDefaultSignalAccountId } from "./accounts.js";
|
||||
import { resolveSignalTarget } from "./aliases.js";
|
||||
import {
|
||||
hasSignalApprovalReactionApprovers,
|
||||
registerSignalApprovalReactionTarget,
|
||||
resolveSignalApprovalConversationKey,
|
||||
resolveSignalApprovalTargetAuthorKeys,
|
||||
unregisterSignalApprovalReactionTarget,
|
||||
} from "./approval-reactions.js";
|
||||
import { normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
import { sendMessageSignal, sendTypingSignal } from "./send.js";
|
||||
|
||||
const log = createSubsystemLogger("signal/approvals");
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type SignalPendingDelivery = ApprovalReactionPendingContent;
|
||||
type PreparedSignalApprovalTarget = {
|
||||
to: string;
|
||||
accountId: string;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountUuid?: string;
|
||||
targetAuthorKeys: readonly string[];
|
||||
};
|
||||
type PendingSignalApprovalEntry = {
|
||||
accountId: string;
|
||||
to: string;
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
targetAuthorKeys: readonly string[];
|
||||
reactionsActive: boolean;
|
||||
};
|
||||
type SignalFinalPayload = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type SignalApprovalRuntimeContext = {
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountUuid?: string;
|
||||
};
|
||||
|
||||
function readSignalApprovalRuntimeContext(context: unknown): SignalApprovalRuntimeContext {
|
||||
const value = context as
|
||||
| { baseUrl?: unknown; account?: unknown; accountUuid?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
return {
|
||||
baseUrl:
|
||||
typeof value?.baseUrl === "string" && value.baseUrl.trim() ? value.baseUrl.trim() : undefined,
|
||||
account:
|
||||
typeof value?.account === "string" && value.account.trim() ? value.account.trim() : undefined,
|
||||
accountUuid:
|
||||
typeof value?.accountUuid === "string" && value.accountUuid.trim()
|
||||
? value.accountUuid.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPendingPayload(params: {
|
||||
request: ApprovalRequest;
|
||||
nowMs: number;
|
||||
view: PendingApprovalView;
|
||||
}): SignalPendingDelivery {
|
||||
return buildApprovalReactionPendingContent(params);
|
||||
}
|
||||
|
||||
export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
||||
SignalPendingDelivery,
|
||||
PreparedSignalApprovalTarget,
|
||||
PendingSignalApprovalEntry,
|
||||
true,
|
||||
SignalFinalPayload
|
||||
>({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
availability: {
|
||||
isConfigured: ({ context }) => Boolean(context),
|
||||
shouldHandle: ({ context }) => Boolean(context),
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: ({ request, nowMs, view }) =>
|
||||
buildPendingPayload({ request, nowMs, view }),
|
||||
buildResolvedResult: ({ request, resolved, view }) => ({
|
||||
kind: "update",
|
||||
payload: { text: buildChannelApprovalResolvedText({ request, resolved, view }) },
|
||||
}),
|
||||
buildExpiredResult: ({ request, view }) => ({
|
||||
kind: "update",
|
||||
payload: { text: buildChannelApprovalExpiredText({ request, view }) },
|
||||
}),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: ({ cfg, plannedTarget, accountId, context }) => {
|
||||
const plannedAccountId = (plannedTarget.target as { accountId?: string | null }).accountId;
|
||||
const explicitAccountId = resolvePreparedApprovalAccountId({
|
||||
plannedAccountId,
|
||||
contextAccountId: accountId,
|
||||
});
|
||||
const preparedAccountId = resolvePreparedApprovalAccountId({
|
||||
plannedAccountId,
|
||||
contextAccountId: accountId,
|
||||
fallbackAccountId: cfg ? resolveDefaultSignalAccountId(cfg) : DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const rawTo = plannedTarget.target.to;
|
||||
let to = normalizeSignalMessagingTarget(rawTo);
|
||||
if (cfg) {
|
||||
try {
|
||||
to =
|
||||
resolveSignalTarget({
|
||||
cfg,
|
||||
accountId: explicitAccountId,
|
||||
input: rawTo,
|
||||
})?.to ?? to;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
const runtimeContext = readSignalApprovalRuntimeContext(context);
|
||||
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys({
|
||||
targetAuthor: runtimeContext.account,
|
||||
targetAuthorUuid: runtimeContext.accountUuid,
|
||||
});
|
||||
const prepared: PreparedSignalApprovalTarget = {
|
||||
to,
|
||||
accountId: preparedAccountId,
|
||||
...(runtimeContext.baseUrl ? { baseUrl: runtimeContext.baseUrl } : {}),
|
||||
...(runtimeContext.account ? { account: runtimeContext.account } : {}),
|
||||
...(runtimeContext.accountUuid ? { accountUuid: runtimeContext.accountUuid } : {}),
|
||||
targetAuthorKeys,
|
||||
};
|
||||
return {
|
||||
dedupeKey: `${prepared.accountId}:${buildChannelApprovalNativeTargetKey({
|
||||
to: prepared.to,
|
||||
})}`,
|
||||
target: prepared,
|
||||
};
|
||||
},
|
||||
deliverPending: async ({ cfg, preparedTarget, pendingPayload }) => {
|
||||
await sendTypingSignal(preparedTarget.to, {
|
||||
cfg,
|
||||
accountId: preparedTarget.accountId,
|
||||
...(preparedTarget.baseUrl ? { baseUrl: preparedTarget.baseUrl } : {}),
|
||||
...(preparedTarget.account ? { account: preparedTarget.account } : {}),
|
||||
}).catch(() => {});
|
||||
const reactionsActive =
|
||||
preparedTarget.targetAuthorKeys.length > 0 &&
|
||||
hasSignalApprovalReactionApprovers({ cfg, accountId: preparedTarget.accountId });
|
||||
const payload = reactionsActive
|
||||
? pendingPayload.reactionPayload
|
||||
: pendingPayload.manualFallbackPayload;
|
||||
const result = await sendMessageSignal(preparedTarget.to, payload.text ?? "", {
|
||||
cfg,
|
||||
accountId: preparedTarget.accountId,
|
||||
...(preparedTarget.baseUrl ? { baseUrl: preparedTarget.baseUrl } : {}),
|
||||
...(preparedTarget.account ? { account: preparedTarget.account } : {}),
|
||||
textMode: "plain",
|
||||
});
|
||||
if (!result.messageId || result.messageId === "unknown") {
|
||||
return null;
|
||||
}
|
||||
const conversationKey = resolveSignalApprovalConversationKey(preparedTarget.to);
|
||||
if (!conversationKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accountId: preparedTarget.accountId,
|
||||
to: preparedTarget.to,
|
||||
conversationKey,
|
||||
messageId: result.messageId,
|
||||
targetAuthorKeys: preparedTarget.targetAuthorKeys,
|
||||
reactionsActive,
|
||||
...(preparedTarget.baseUrl ? { baseUrl: preparedTarget.baseUrl } : {}),
|
||||
...(preparedTarget.account ? { account: preparedTarget.account } : {}),
|
||||
};
|
||||
},
|
||||
updateEntry: async ({ cfg, entry, payload }) => {
|
||||
await sendMessageSignal(entry.to, payload.text, {
|
||||
cfg,
|
||||
accountId: entry.accountId,
|
||||
...(entry.baseUrl ? { baseUrl: entry.baseUrl } : {}),
|
||||
...(entry.account ? { account: entry.account } : {}),
|
||||
textMode: "plain",
|
||||
});
|
||||
},
|
||||
},
|
||||
interactions: {
|
||||
bindPending: ({ entry, request, view, pendingPayload }) => {
|
||||
if (!entry.reactionsActive) {
|
||||
return null;
|
||||
}
|
||||
return registerSignalApprovalReactionTarget({
|
||||
accountId: entry.accountId,
|
||||
conversationKey: entry.conversationKey,
|
||||
messageId: entry.messageId,
|
||||
approvalId: request.id,
|
||||
approvalKind: view.approvalKind,
|
||||
allowedDecisions: pendingPayload.reactionPayload.allowedDecisions,
|
||||
targetAuthorKeys: entry.targetAuthorKeys,
|
||||
route: {
|
||||
deliveryMode: "session",
|
||||
...(normalizeOptionalString(request.request.agentId)
|
||||
? { agentId: normalizeOptionalString(request.request.agentId) }
|
||||
: {}),
|
||||
...(normalizeOptionalString(request.request.sessionKey)
|
||||
? { sessionKey: normalizeOptionalString(request.request.sessionKey) }
|
||||
: {}),
|
||||
},
|
||||
routeAllowed: true,
|
||||
ttlMs: Math.max(1, view.expiresAtMs - Date.now()),
|
||||
})
|
||||
? true
|
||||
: null;
|
||||
},
|
||||
unbindPending: ({ entry }) => {
|
||||
unregisterSignalApprovalReactionTarget({
|
||||
accountId: entry.accountId,
|
||||
conversationKey: entry.conversationKey,
|
||||
messageId: entry.messageId,
|
||||
});
|
||||
},
|
||||
cancelDelivered: ({ entry }) => {
|
||||
unregisterSignalApprovalReactionTarget({
|
||||
accountId: entry.accountId,
|
||||
conversationKey: entry.conversationKey,
|
||||
messageId: entry.messageId,
|
||||
});
|
||||
},
|
||||
},
|
||||
observe: {
|
||||
onDeliveryError: ({ error, request }) => {
|
||||
log.error(`signal approvals: failed to send request ${request.id}: ${String(error)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
538
extensions/signal/src/approval-native.test.ts
Normal file
538
extensions/signal/src/approval-native.test.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
// Signal tests cover approval native plugin behavior.
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
shouldSuppressLocalSignalExecApprovalPrompt,
|
||||
signalApprovalCapability,
|
||||
} from "./approval-native.js";
|
||||
|
||||
type SignalConfig = NonNullable<NonNullable<OpenClawConfig["channels"]>["signal"]>;
|
||||
|
||||
function buildConfig(
|
||||
params: {
|
||||
signal?: Partial<SignalConfig>;
|
||||
approvals?: OpenClawConfig["approvals"];
|
||||
} = {},
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
signal: {
|
||||
enabled: true,
|
||||
...params.signal,
|
||||
},
|
||||
},
|
||||
approvals: params.approvals,
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function buildExecRequest(
|
||||
turnSourceTo: string,
|
||||
overrides: Partial<ExecApprovalRequest["request"]> = {},
|
||||
): ExecApprovalRequest {
|
||||
return {
|
||||
id: "exec-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
agentId: "main",
|
||||
turnSourceChannel: "signal",
|
||||
turnSourceTo,
|
||||
turnSourceAccountId: "default",
|
||||
sessionKey: `agent:main:signal:${turnSourceTo}`,
|
||||
...overrides,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginRequest(
|
||||
turnSourceTo: string,
|
||||
overrides: Partial<PluginApprovalRequest["request"]> = {},
|
||||
): PluginApprovalRequest {
|
||||
return {
|
||||
id: "plugin:approval-1",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Allow plugin action",
|
||||
agentId: "main",
|
||||
turnSourceChannel: "signal",
|
||||
turnSourceTo,
|
||||
turnSourceAccountId: "default",
|
||||
sessionKey: `agent:main:signal:${turnSourceTo}`,
|
||||
...overrides,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function nativeShouldHandle(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest | PluginApprovalRequest;
|
||||
accountId?: string | null;
|
||||
}) {
|
||||
return signalApprovalCapability.nativeRuntime?.availability.shouldHandle({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? "default",
|
||||
context: {},
|
||||
request: params.request,
|
||||
});
|
||||
}
|
||||
|
||||
function buildLocalApprovalPayload(
|
||||
params: {
|
||||
approvalKind?: "exec" | "plugin";
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
text: "Approval required.",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: params.approvalKind === "plugin" ? "plugin:approval-1" : "exec-1",
|
||||
approvalSlug: params.approvalKind === "plugin" ? "plugin:approval-1" : "exec-1",
|
||||
approvalKind: params.approvalKind ?? "exec",
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("signal approval capability", () => {
|
||||
it("does not enable exec or plugin native approvals from Signal readiness alone", () => {
|
||||
const cfg = buildConfig();
|
||||
const execRequest = buildExecRequest("+15551230000");
|
||||
const pluginRequest = buildPluginRequest("+15551230000");
|
||||
|
||||
expect(
|
||||
signalApprovalCapability?.getActionAvailabilityState?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ kind: "disabled" });
|
||||
expect(
|
||||
signalApprovalCapability?.getActionAvailabilityState?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
).toEqual({ kind: "disabled" });
|
||||
expect(
|
||||
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: execRequest,
|
||||
}).enabled,
|
||||
).toBe(false);
|
||||
expect(nativeShouldHandle({ cfg, request: execRequest })).toBe(false);
|
||||
expect(nativeShouldHandle({ cfg, request: pluginRequest })).toBe(false);
|
||||
});
|
||||
|
||||
it("allows session-mode exec delivery for matching Signal origins", () => {
|
||||
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
|
||||
const request = buildExecRequest("+15551230000");
|
||||
|
||||
expect(
|
||||
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: false,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
});
|
||||
expect(nativeShouldHandle({ cfg, request })).toBe(true);
|
||||
});
|
||||
|
||||
it("requires explicit approvers before delivering group-origin approvals", () => {
|
||||
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
|
||||
const request = buildExecRequest("group:g1");
|
||||
|
||||
expect(
|
||||
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
}).enabled,
|
||||
).toBe(false);
|
||||
|
||||
const withApprover = buildConfig({
|
||||
signal: { allowFrom: ["+15551230000"] },
|
||||
approvals: { exec: { enabled: true } },
|
||||
});
|
||||
expect(
|
||||
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg: withApprover,
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
}).enabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps exec and plugin forwarding gates independent", () => {
|
||||
const execOnly = buildConfig({ approvals: { exec: { enabled: true } } });
|
||||
const pluginOnly = buildConfig({ approvals: { plugin: { enabled: true } } });
|
||||
|
||||
expect(nativeShouldHandle({ cfg: execOnly, request: buildPluginRequest("+15551230000") })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(nativeShouldHandle({ cfg: pluginOnly, request: buildExecRequest("+15551230000") })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
nativeShouldHandle({ cfg: pluginOnly, request: buildPluginRequest("+15551230000") }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not use session mode for non-Signal-origin requests", () => {
|
||||
const cfg = buildConfig({ approvals: { exec: { enabled: true } } });
|
||||
const request = buildExecRequest("", {
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "C123",
|
||||
sessionKey: "agent:main:slack:channel:c123",
|
||||
});
|
||||
|
||||
expect(nativeShouldHandle({ cfg, request })).toBe(false);
|
||||
expect(
|
||||
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
}).enabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses target-mode config for requestless availability without native runtime handling", () => {
|
||||
const cfg = buildConfig({
|
||||
signal: { allowFrom: ["+15551230000"] },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const request = buildExecRequest("+15551230000");
|
||||
|
||||
expect(
|
||||
signalApprovalCapability?.getActionAvailabilityState?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ kind: "enabled" });
|
||||
expect(
|
||||
signalApprovalCapability.nativeRuntime?.availability.isConfigured({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
context: {},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(nativeShouldHandle({ cfg, request })).toBe(false);
|
||||
expect(
|
||||
signalApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
}).enabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("renders target-mode exec prompts without unbound reaction choices", () => {
|
||||
const cfg = buildConfig({
|
||||
signal: { allowFrom: ["+15551230000"] },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const request = buildExecRequest("+15551230000", {
|
||||
ask: "always",
|
||||
cwd: "/tmp/work",
|
||||
host: "gateway",
|
||||
});
|
||||
|
||||
const payload = signalApprovalCapability.render?.exec?.buildPendingPayload?.({
|
||||
cfg,
|
||||
request,
|
||||
target: { channel: "signal", to: "+15551230000", source: "target" },
|
||||
nowMs: 0,
|
||||
});
|
||||
const text = payload?.text ?? "";
|
||||
|
||||
expect(text).toContain("/approve exec-1 allow-once");
|
||||
expect(text).not.toContain("React with:");
|
||||
expect(text).not.toContain("👍 Allow Once");
|
||||
expect(text).not.toContain("👎 Deny");
|
||||
expect(text).not.toContain("<id>");
|
||||
expect(text).not.toContain("1️⃣ Allow Once");
|
||||
expect(text).not.toContain("2️⃣ Allow Always");
|
||||
expect(text).not.toContain("3️⃣ Deny");
|
||||
});
|
||||
|
||||
it("does not show reaction choices when Signal has no explicit approvers", () => {
|
||||
const cfg = buildConfig({
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const request = buildExecRequest("+15551230000");
|
||||
|
||||
const payload = signalApprovalCapability.render?.exec?.buildPendingPayload?.({
|
||||
cfg,
|
||||
request,
|
||||
target: { channel: "signal", to: "+15551230000", source: "target" },
|
||||
nowMs: 0,
|
||||
});
|
||||
const text = payload?.text ?? "";
|
||||
|
||||
expect(text).toContain("/approve exec-1 allow-once");
|
||||
expect(text).not.toContain("React with:");
|
||||
expect(text).not.toContain("👍 Allow Once");
|
||||
expect(text).not.toContain("👎 Deny");
|
||||
});
|
||||
|
||||
it("normalizes equivalent Signal UUID target forms without suppressing generic target delivery", () => {
|
||||
const cfg = buildConfig({
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "uuid:ABCDEF12-3456-7890-ABCD-EF1234567890" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const request = buildExecRequest("abcdef12-3456-7890-abcd-ef1234567890", {
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "C123",
|
||||
sessionKey: "agent:main:slack:channel:c123",
|
||||
});
|
||||
|
||||
expect(
|
||||
signalApprovalCapability.delivery?.shouldSuppressForwardingFallback?.({
|
||||
cfg,
|
||||
approvalKind: "exec",
|
||||
request,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
source: "target",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSuppressLocalSignalExecApprovalPrompt", () => {
|
||||
const activeExecHint = {
|
||||
kind: "approval-pending",
|
||||
approvalKind: "exec",
|
||||
nativeRouteActive: true,
|
||||
} as const;
|
||||
|
||||
it("suppresses eligible session-mode exec approval prompts", () => {
|
||||
const cfg = buildConfig({
|
||||
signal: { allowFrom: ["+15551230000"] },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
agentFilter: ["main"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
payload: buildLocalApprovalPayload({
|
||||
agentId: null,
|
||||
sessionKey: "agent:main:signal:+15551230000",
|
||||
}),
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps local prompts for disabled, ambiguous, or non-exec cases", () => {
|
||||
const enabledConfig = buildConfig({
|
||||
signal: { allowFrom: ["+15551230000"] },
|
||||
approvals: { exec: { enabled: true } },
|
||||
});
|
||||
const payload = buildLocalApprovalPayload({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:+15551230000",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg: buildConfig(),
|
||||
payload,
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg: buildConfig({
|
||||
signal: { allowFrom: ["+15551230000"] },
|
||||
approvals: { exec: { enabled: false } },
|
||||
}),
|
||||
payload,
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg: buildConfig({
|
||||
signal: { allowFrom: ["+15551230000"] },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
payload,
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg: enabledConfig,
|
||||
payload,
|
||||
hint: { ...activeExecHint, nativeRouteActive: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg: enabledConfig,
|
||||
payload: buildLocalApprovalPayload({ approvalKind: "plugin" }),
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg: enabledConfig,
|
||||
payload: { text: "Approval required." },
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses direct same-chat Signal prompts without explicit approvers", () => {
|
||||
const cfg = buildConfig({
|
||||
approvals: { exec: { enabled: true } },
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg,
|
||||
payload: buildLocalApprovalPayload({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:+15551230000",
|
||||
}),
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps no-approver local prompts for ambiguous or group Signal sessions", () => {
|
||||
const cfg = buildConfig({
|
||||
approvals: { exec: { enabled: true } },
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg,
|
||||
payload: buildLocalApprovalPayload({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:group:test-group",
|
||||
}),
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg,
|
||||
payload: buildLocalApprovalPayload({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:slack:C123",
|
||||
}),
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("applies top-level approval filters with agent fallback from session key", () => {
|
||||
const cfg = buildConfig({
|
||||
signal: { allowFrom: ["+15551230000"] },
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
agentFilter: ["ops"],
|
||||
sessionFilter: ["signal"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg,
|
||||
payload: buildLocalApprovalPayload({
|
||||
agentId: null,
|
||||
sessionKey: "agent:ops:signal:+15551230000",
|
||||
}),
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg,
|
||||
payload: buildLocalApprovalPayload({
|
||||
agentId: null,
|
||||
sessionKey: "agent:main:signal:+15551230000",
|
||||
}),
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg,
|
||||
payload: buildLocalApprovalPayload({
|
||||
agentId: null,
|
||||
sessionKey: "agent:ops:slack:C123",
|
||||
}),
|
||||
hint: activeExecHint,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
323
extensions/signal/src/approval-native.ts
Normal file
323
extensions/signal/src/approval-native.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
// Signal plugin module implements approval native behavior.
|
||||
import { createChannelApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime";
|
||||
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import {
|
||||
createChannelApproverDmTargetResolver,
|
||||
createChannelNativeOriginTargetResolver,
|
||||
createNativeApprovalChannelRouteGates,
|
||||
createNativeApprovalForwardingFallbackSuppressor,
|
||||
shouldSuppressLocalNativeExecApprovalPrompt,
|
||||
} from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import { buildApprovalReactionPendingContentForRequest } from "openclaw/plugin-sdk/approval-reaction-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type {
|
||||
ChannelApprovalCapability,
|
||||
ChannelOutboundPayloadHint,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
} from "./accounts.js";
|
||||
import { getSignalApprovalApprovers, signalApprovalAuth } from "./approval-auth.js";
|
||||
import { normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type ApprovalKind = "exec" | "plugin";
|
||||
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["exec"]>;
|
||||
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
|
||||
type ChannelApprovalForwardTarget = Parameters<
|
||||
NonNullable<
|
||||
NonNullable<ChannelApprovalCapability["delivery"]>["shouldSuppressForwardingFallback"]
|
||||
>
|
||||
>[0]["target"];
|
||||
type SignalApprovalTarget = {
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
|
||||
const DEFAULT_APPROVAL_FORWARDING_MODE: ApprovalForwardingMode = "session";
|
||||
|
||||
function isSignalApprovalTransportEnabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
return resolveSignalAccount({ cfg: params.cfg, accountId: params.accountId }).enabled;
|
||||
}
|
||||
|
||||
function normalizeSignalForwardTarget(
|
||||
target: Pick<ChannelApprovalForwardTarget, "channel" | "to" | "accountId" | "threadId">,
|
||||
): SignalApprovalTarget | null {
|
||||
if (normalizeLowercaseStringOrEmpty(target.channel) !== "signal") {
|
||||
return null;
|
||||
}
|
||||
const to = normalizeSignalMessagingTarget(target.to);
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to,
|
||||
accountId: normalizeOptionalString(target.accountId),
|
||||
threadId: target.threadId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTurnSourceSignalOriginTarget(
|
||||
request: ApprovalRequest,
|
||||
): SignalApprovalTarget | null {
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
if (turnSourceChannel !== "signal") {
|
||||
return null;
|
||||
}
|
||||
const to = normalizeSignalMessagingTarget(request.request.turnSourceTo ?? "");
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to,
|
||||
accountId: normalizeOptionalString(request.request.turnSourceAccountId),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionSignalOriginTarget(sessionTarget: {
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
}): SignalApprovalTarget | null {
|
||||
const to = normalizeSignalMessagingTarget(sessionTarget.to);
|
||||
return to ? { to, accountId: normalizeOptionalString(sessionTarget.accountId) } : null;
|
||||
}
|
||||
|
||||
const signalApprovalRouteGates = createNativeApprovalChannelRouteGates({
|
||||
channel: "signal",
|
||||
defaultForwardingMode: DEFAULT_APPROVAL_FORWARDING_MODE,
|
||||
isTransportEnabled: isSignalApprovalTransportEnabled,
|
||||
listAccountIds: listSignalAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultSignalAccountId,
|
||||
normalizeForwardTarget: normalizeSignalForwardTarget,
|
||||
resolveTurnSourceTarget: resolveTurnSourceSignalOriginTarget,
|
||||
});
|
||||
|
||||
const {
|
||||
canApprovalPotentiallyRouteToChannel: canApprovalPotentiallyRouteToSignal,
|
||||
canAnyApprovalPotentiallyRouteToChannel: canAnyApprovalPotentiallyRouteToSignal,
|
||||
isNativeApprovalHandlerConfigured: isSignalNativeApprovalHandlerConfiguredBase,
|
||||
isSessionApprovalEligible: isSignalSessionApprovalEligible,
|
||||
shouldHandleApprovalRequest: shouldHandleSignalApprovalRequest,
|
||||
} = signalApprovalRouteGates;
|
||||
|
||||
export function isSignalNativeApprovalHandlerConfigured(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
return isSignalNativeApprovalHandlerConfiguredBase(params);
|
||||
}
|
||||
|
||||
function resolveSignalSessionTargetFromSessionKey(sessionKey?: string | null): string | null {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
const rest = parsed?.rest ?? normalizeOptionalString(sessionKey);
|
||||
if (!rest || !normalizeLowercaseStringOrEmpty(rest).startsWith("signal:")) {
|
||||
return null;
|
||||
}
|
||||
return normalizeSignalMessagingTarget(rest.slice("signal:".length)) ?? null;
|
||||
}
|
||||
|
||||
export function shouldSuppressLocalSignalExecApprovalPrompt(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
payload: ReplyPayload;
|
||||
hint?: ChannelOutboundPayloadHint;
|
||||
}): boolean {
|
||||
return shouldSuppressLocalNativeExecApprovalPrompt({
|
||||
...params,
|
||||
isTransportEnabled: isSignalApprovalTransportEnabled,
|
||||
isSessionRouteEligible: ({ cfg, accountId, metadata }) => {
|
||||
if (getSignalApprovalApprovers({ cfg, accountId }).length > 0) {
|
||||
return true;
|
||||
}
|
||||
const sessionTarget = resolveSignalSessionTargetFromSessionKey(metadata.sessionKey);
|
||||
return Boolean(sessionTarget && !isSignalGroupTarget(sessionTarget));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const resolveSignalOriginTargetBase = createChannelNativeOriginTargetResolver({
|
||||
channel: "signal",
|
||||
shouldHandleRequest: shouldHandleSignalApprovalRequest,
|
||||
resolveTurnSourceTarget: resolveTurnSourceSignalOriginTarget,
|
||||
resolveSessionTarget: resolveSessionSignalOriginTarget,
|
||||
normalizeTarget: (target) => {
|
||||
const to = normalizeSignalMessagingTarget(target.to);
|
||||
return to ? { ...target, to } : null;
|
||||
},
|
||||
});
|
||||
|
||||
function isSignalGroupTarget(to: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(to).startsWith("group:");
|
||||
}
|
||||
|
||||
function resolveSignalOriginTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind?: ApprovalKind;
|
||||
request: ApprovalRequest;
|
||||
}): SignalApprovalTarget | null {
|
||||
const target = resolveSignalOriginTargetBase(params);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
isSignalGroupTarget(target.to) &&
|
||||
getSignalApprovalApprovers({ cfg: params.cfg, accountId: params.accountId }).length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
const resolveSignalApproverDmTargets = createChannelApproverDmTargetResolver({
|
||||
shouldHandleRequest: shouldHandleSignalApprovalRequest,
|
||||
resolveApprovers: getSignalApprovalApprovers,
|
||||
mapApprover: (approver, params) => {
|
||||
const to = normalizeSignalMessagingTarget(approver);
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to,
|
||||
accountId: normalizeOptionalString(params.accountId),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const shouldSuppressSignalForwardingFallback =
|
||||
createNativeApprovalForwardingFallbackSuppressor<SignalApprovalTarget>({
|
||||
channel: "signal",
|
||||
normalizeForwardTarget: normalizeSignalForwardTarget,
|
||||
resolveAccountId: ({ forwardingTarget, request }) =>
|
||||
forwardingTarget.accountId ?? normalizeOptionalString(request.request.turnSourceAccountId),
|
||||
resolveForwardingTargetForMatch: ({ forwardingTarget, accountId }) => ({
|
||||
...forwardingTarget,
|
||||
accountId,
|
||||
}),
|
||||
isSessionRouteEligible: isSignalSessionApprovalEligible,
|
||||
resolveOriginTarget: resolveSignalOriginTarget,
|
||||
resolveApproverDmTargets: resolveSignalApproverDmTargets,
|
||||
});
|
||||
|
||||
function buildSignalExecPendingPayload(params: { request: ExecApprovalRequest; nowMs: number }) {
|
||||
return buildApprovalReactionPendingContentForRequest(params).manualFallbackPayload;
|
||||
}
|
||||
|
||||
function buildSignalPluginPendingPayload(params: {
|
||||
request: PluginApprovalRequest;
|
||||
nowMs: number;
|
||||
}) {
|
||||
return buildApprovalReactionPendingContentForRequest(params).manualFallbackPayload;
|
||||
}
|
||||
|
||||
export const signalApprovalCapability: ChannelApprovalCapability = createChannelApprovalCapability({
|
||||
...signalApprovalAuth,
|
||||
getActionAvailabilityState: ({ cfg, accountId, approvalKind }) =>
|
||||
(
|
||||
approvalKind
|
||||
? canApprovalPotentiallyRouteToSignal({ cfg, accountId, approvalKind })
|
||||
: canAnyApprovalPotentiallyRouteToSignal({ cfg, accountId })
|
||||
)
|
||||
? ({ kind: "enabled" } as const)
|
||||
: ({ kind: "disabled" } as const),
|
||||
getExecInitiatingSurfaceState: ({ cfg, accountId }) =>
|
||||
canApprovalPotentiallyRouteToSignal({ cfg, accountId, approvalKind: "exec" })
|
||||
? ({ kind: "enabled" } as const)
|
||||
: ({ kind: "disabled" } as const),
|
||||
describeExecApprovalSetup: ({ accountId }) => {
|
||||
const prefix =
|
||||
accountId && accountId !== "default"
|
||||
? `channels.signal.accounts.${accountId}`
|
||||
: "channels.signal";
|
||||
return `Signal supports native exec approvals for this account when \`approvals.exec.enabled\` is true and the route allows Signal. Link Signal and keep the gateway running; configure \`${prefix}.allowFrom\` to restrict approvers.`;
|
||||
},
|
||||
delivery: {
|
||||
hasConfiguredDmRoute: ({ cfg }) =>
|
||||
listSignalAccountIds(cfg).some((accountId) => {
|
||||
if (
|
||||
!canAnyApprovalPotentiallyRouteToSignal({
|
||||
cfg,
|
||||
accountId,
|
||||
nativeSessionOnly: true,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return getSignalApprovalApprovers({ cfg, accountId }).length > 0;
|
||||
}),
|
||||
shouldSuppressForwardingFallback: shouldSuppressSignalForwardingFallback,
|
||||
},
|
||||
render: {
|
||||
exec: {
|
||||
buildPendingPayload: ({ request, nowMs }) =>
|
||||
buildSignalExecPendingPayload({
|
||||
request,
|
||||
nowMs,
|
||||
}),
|
||||
},
|
||||
plugin: {
|
||||
buildPendingPayload: ({ request, nowMs }) =>
|
||||
buildSignalPluginPendingPayload({
|
||||
request,
|
||||
nowMs,
|
||||
}),
|
||||
},
|
||||
},
|
||||
native: {
|
||||
describeDeliveryCapabilities: ({ cfg, accountId, approvalKind, request }) => {
|
||||
const originTarget = resolveSignalOriginTarget({
|
||||
cfg,
|
||||
accountId,
|
||||
approvalKind,
|
||||
request,
|
||||
});
|
||||
const approverTargets = resolveSignalApproverDmTargets({
|
||||
cfg,
|
||||
accountId,
|
||||
approvalKind,
|
||||
request,
|
||||
});
|
||||
const enabled = Boolean(originTarget) || approverTargets.length > 0;
|
||||
return {
|
||||
enabled,
|
||||
preferredSurface: originTarget ? "origin" : "approver-dm",
|
||||
supportsOriginSurface: Boolean(originTarget),
|
||||
supportsApproverDmSurface: approverTargets.length > 0,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
};
|
||||
},
|
||||
resolveOriginTarget: resolveSignalOriginTarget,
|
||||
resolveApproverDmTargets: resolveSignalApproverDmTargets,
|
||||
},
|
||||
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
isConfigured: ({ cfg, accountId, context }) =>
|
||||
Boolean(context) &&
|
||||
isSignalNativeApprovalHandlerConfigured({
|
||||
cfg,
|
||||
accountId,
|
||||
}),
|
||||
shouldHandle: ({ cfg, accountId, context, request }) =>
|
||||
Boolean(context) && shouldHandleSignalApprovalRequest({ cfg, accountId, request }),
|
||||
load: async () =>
|
||||
(await import("./approval-handler.runtime.js"))
|
||||
.signalApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
|
||||
}),
|
||||
});
|
||||
871
extensions/signal/src/approval-reactions.test.ts
Normal file
871
extensions/signal/src/approval-reactions.test.ts
Normal file
@@ -0,0 +1,871 @@
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
// Signal tests cover approval reactions plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSignalApprovalReactionHintToText,
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
buildSignalApprovalReactionHint,
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
maybeResolveSignalApprovalReaction,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
registerSignalApprovalReactionTarget,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
|
||||
const resolverMocks = vi.hoisted(() => ({
|
||||
resolveSignalApproval: vi.fn(),
|
||||
isApprovalNotFoundError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("./approval-resolver.js", () => ({
|
||||
resolveSignalApproval: resolverMocks.resolveSignalApproval,
|
||||
isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError,
|
||||
}));
|
||||
|
||||
const approvalRoute = {
|
||||
deliveryMode: "session" as const,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
};
|
||||
|
||||
describe("Signal approval reactions", () => {
|
||||
beforeEach(() => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
resolverMocks.resolveSignalApproval.mockReset();
|
||||
resolverMocks.resolveSignalApproval.mockResolvedValue(undefined);
|
||||
resolverMocks.isApprovalNotFoundError.mockReset();
|
||||
resolverMocks.isApprovalNotFoundError.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("renders thumbs-only reaction choices for allowed decisions", () => {
|
||||
expect(buildSignalApprovalReactionHint(["allow-once", "deny"])).toBe(
|
||||
"React with:\n\n👍 Allow Once\n👎 Deny",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes allow-always as a reaction choice when allowed", () => {
|
||||
expect(buildSignalApprovalReactionHint(["allow-once", "allow-always", "deny"])).toBe(
|
||||
"React with:\n\n👍 Allow Once\n♾️ Allow Always\n👎 Deny",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends thumbs-only reaction choices to outbound approval prompts", () => {
|
||||
expect(
|
||||
addSignalApprovalReactionHintToText({
|
||||
text: "Exec approval required\nID: exec-1\n\nReply with: /approve exec-1 allow-once|deny",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
).toBe(
|
||||
"Exec approval required\nID: exec-1\n\nReact with:\n\n👍 Allow Once\n👎 Deny\n\nReply with: /approve exec-1 allow-once|deny",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate reaction choices on native approval prompts", () => {
|
||||
const prompt = [
|
||||
"Plugin approval required",
|
||||
"Reply with: /approve plugin:abc allow-once|allow-always|deny",
|
||||
"",
|
||||
"React with:",
|
||||
"",
|
||||
"👍 Allow Once",
|
||||
"👎 Deny",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
addSignalApprovalReactionHintToText({
|
||||
text: prompt,
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
).toBe(prompt);
|
||||
});
|
||||
|
||||
it("registers delivered structured approval payloads for reactions", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-structured-approval",
|
||||
approvalSlug: "exec-str",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000012",
|
||||
toJid: "+15551230000",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000012",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-structured-approval",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not register metadata-only approval payloads without visible reaction hints", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-hidden-reaction",
|
||||
approvalSlug: "exec-hid",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf hidden",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000015",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000015",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("registers only delivered chunks that contain visible reaction hints", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-chunked-reaction",
|
||||
approvalSlug: "exec-ch",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf chunked",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000016",
|
||||
meta: {
|
||||
signalVisibleText: "Exec approval required\n\nReact with:\n\n👍 Allow Once\n👎 Deny",
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000017",
|
||||
meta: {
|
||||
signalVisibleText: "Continuation chunk without controls",
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000016",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "exec-chunked-reaction",
|
||||
decision: "allow-once",
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000017",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("registers delivered structured plugin approval payloads using metadata kind", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildPluginApprovalPendingReplyPayload({
|
||||
request: {
|
||||
id: "plugin-structured-approval",
|
||||
request: {
|
||||
title: "Sensitive plugin action",
|
||||
description: "Needs approval",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
},
|
||||
nowMs: 1_000,
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000013",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000013",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "plugin-structured-approval",
|
||||
approvalKind: "plugin",
|
||||
decision: "allow-once",
|
||||
});
|
||||
});
|
||||
|
||||
it("registers alias-configured delivered prompts under the canonical target", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
aliases: {
|
||||
me: "+15551230000",
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "signal:me" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildPluginApprovalPendingReplyPayload({
|
||||
request: {
|
||||
id: "plugin:abc",
|
||||
request: {
|
||||
title: "Sensitive plugin action",
|
||||
description: "Needs approval",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
pluginId: "demo",
|
||||
toolName: "dangerousTool",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
},
|
||||
nowMs: 1_000,
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(deliveredPayload?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000010",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000010",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "plugin:abc",
|
||||
approvalKind: "plugin",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "me",
|
||||
messageId: "1700000000010",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not register delivered structured approval payloads without explicit approvers", () => {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-no-approvers",
|
||||
approvalSlug: "exec-no",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
});
|
||||
const deliveredPayload = {
|
||||
...payload,
|
||||
text: addSignalApprovalReactionHintToText({
|
||||
text: payload.text ?? "",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
};
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000014",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("registers reaction state when only allow-always is available", async () => {
|
||||
expect(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000000",
|
||||
approvalId: "exec-allow-always",
|
||||
allowedDecisions: ["allow-always"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
}),
|
||||
).toEqual({
|
||||
approvalId: "exec-allow-always",
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-always"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000000",
|
||||
reactionKey: "♾️",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-allow-always",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-always",
|
||||
route: approvalRoute,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a registered reaction target", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000000",
|
||||
approvalId: "exec-1",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000000",
|
||||
reactionKey: "👎",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-1",
|
||||
approvalKind: "exec",
|
||||
decision: "deny",
|
||||
route: approvalRoute,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not match timestamp-only bindings when the inbound conversation id differs", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "username:kevin",
|
||||
messageId: "1700000000001",
|
||||
approvalId: "exec-1",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000001",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes UUID target-author casing before matching", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000001",
|
||||
approvalId: "exec-1",
|
||||
allowedDecisions: ["allow-once"],
|
||||
targetAuthorKeys: ["uuid:ABCDEF12-3456-7890-ABCD-EF1234567890"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000001",
|
||||
reactionKey: "👍",
|
||||
targetAuthorUuid: "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-1",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: approvalRoute,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unsupported numeric approval reaction choices", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000002",
|
||||
approvalId: "exec-1",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
for (const reactionKey of ["1️⃣", "2️⃣", "3️⃣", "1", "2", "3"]) {
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000002",
|
||||
reactionKey,
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires the reaction target author to match the outbound bot identity", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000006",
|
||||
approvalId: "exec-1",
|
||||
allowedDecisions: ["allow-once"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000006",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550008888",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000006",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-1",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: approvalRoute,
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes reactions using Signal approval approvers", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "group:g1",
|
||||
messageId: "1700000000003",
|
||||
approvalId: "plugin:abc",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
|
||||
const handled = await maybeResolveSignalApprovalReaction({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "session",
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
conversationKey: "group:g1",
|
||||
messageId: "1700000000003",
|
||||
reactionKey: "👍",
|
||||
actorId: "+15551230000",
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(resolverMocks.resolveSignalApproval).toHaveBeenCalledWith({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "session",
|
||||
},
|
||||
},
|
||||
},
|
||||
approvalId: "plugin:abc",
|
||||
decision: "allow-once",
|
||||
senderId: "+15551230000",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes reactions using Signal defaultTo approvers", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000008",
|
||||
approvalId: "exec-default-to",
|
||||
allowedDecisions: ["allow-once"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
|
||||
const handled = await maybeResolveSignalApprovalReaction({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: [],
|
||||
defaultTo: "+15551230000",
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "session",
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000008",
|
||||
reactionKey: "👍",
|
||||
actorId: "+15551230000",
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(resolverMocks.resolveSignalApproval).toHaveBeenCalledWith({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: [],
|
||||
defaultTo: "+15551230000",
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "session",
|
||||
},
|
||||
},
|
||||
},
|
||||
approvalId: "exec-default-to",
|
||||
decision: "allow-once",
|
||||
senderId: "+15551230000",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires explicit approvers for approval reactions", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000004",
|
||||
approvalId: "exec-1",
|
||||
allowedDecisions: ["allow-once"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
|
||||
const handled = await maybeResolveSignalApprovalReaction({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "session",
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000004",
|
||||
reactionKey: "👍",
|
||||
actorId: "+15551230000",
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(resolverMocks.resolveSignalApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-checks the top-level approval route before resolving reactions", async () => {
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000007",
|
||||
approvalId: "exec-1",
|
||||
allowedDecisions: ["allow-once"],
|
||||
targetAuthorKeys: ["+15550009999"],
|
||||
route: approvalRoute,
|
||||
routeAllowed: true,
|
||||
});
|
||||
|
||||
const handled = await maybeResolveSignalApprovalReaction({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "session",
|
||||
agentFilter: ["other-agent"],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000007",
|
||||
reactionKey: "👍",
|
||||
actorId: "+15551230000",
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(resolverMocks.resolveSignalApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
789
extensions/signal/src/approval-reactions.ts
Normal file
789
extensions/signal/src/approval-reactions.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
// Signal plugin module implements approval reactions behavior.
|
||||
import { matchesApprovalRequestFilters } from "openclaw/plugin-sdk/approval-client-runtime";
|
||||
import {
|
||||
addApprovalReactionHintToText,
|
||||
buildApprovalReactionHint,
|
||||
createApprovalReactionTargetStore,
|
||||
hasApprovalReactionHintText,
|
||||
listApprovalReactionBindings,
|
||||
resolveApprovalReactionTarget,
|
||||
type ApprovalReactionDecisionBinding,
|
||||
type ApprovalReactionTargetRecord,
|
||||
} from "openclaw/plugin-sdk/approval-reaction-runtime";
|
||||
import {
|
||||
getExecApprovalReplyMetadata,
|
||||
type ExecApprovalReplyDecision,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveSignalTarget } from "./aliases.js";
|
||||
import { getSignalApprovalApprovers, signalApprovalAuth } from "./approval-auth.js";
|
||||
import { looksLikeUuid } from "./identity.js";
|
||||
import { normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
import { getOptionalSignalRuntime } from "./runtime.js";
|
||||
|
||||
const PERSISTENT_NAMESPACE = "signal.approval-reactions.v2";
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type SignalApprovalReactionBinding = ApprovalReactionDecisionBinding;
|
||||
|
||||
type SignalApprovalReactionResolution = {
|
||||
approvalId: string;
|
||||
approvalKind: ApprovalKind;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
route: SignalApprovalReactionRoute;
|
||||
};
|
||||
|
||||
type ApprovalKind = "exec" | "plugin";
|
||||
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["exec"]>;
|
||||
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
|
||||
|
||||
type SignalApprovalReactionRoute =
|
||||
| {
|
||||
deliveryMode: "session";
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
}
|
||||
| {
|
||||
deliveryMode: "target";
|
||||
to: string;
|
||||
accountId?: string;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
type SignalApprovalReactionTarget = ApprovalReactionTargetRecord<SignalApprovalReactionRoute> & {
|
||||
approvalKind: ApprovalKind;
|
||||
targetAuthorKeys: readonly string[];
|
||||
route: SignalApprovalReactionRoute;
|
||||
};
|
||||
|
||||
type SignalApprovalDeliveryTarget = {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
type SignalApprovalDeliveryResult = {
|
||||
channel?: string;
|
||||
messageId?: string | null;
|
||||
toJid?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const resolverRuntimeLoader = createLazyRuntimeModule(() => import("./approval-resolver.js"));
|
||||
|
||||
const signalApprovalReactionTargets =
|
||||
createApprovalReactionTargetStore<SignalApprovalReactionTarget>({
|
||||
namespace: PERSISTENT_NAMESPACE,
|
||||
maxEntries: PERSISTENT_MAX_ENTRIES,
|
||||
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
|
||||
openStore: (storeParams) => getOptionalSignalRuntime()?.state.openKeyedStore(storeParams),
|
||||
logPersistentError: reportPersistentApprovalReactionError,
|
||||
readPersistedTarget,
|
||||
});
|
||||
|
||||
const loadApprovalResolver = resolverRuntimeLoader;
|
||||
|
||||
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
|
||||
return approvalId.startsWith("plugin:") ? "plugin" : "exec";
|
||||
}
|
||||
|
||||
function resolveApprovalForwardingConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
approvalKind: ApprovalKind;
|
||||
}): ApprovalForwardingConfig | undefined {
|
||||
return params.approvalKind === "plugin"
|
||||
? params.cfg.approvals?.plugin
|
||||
: params.cfg.approvals?.exec;
|
||||
}
|
||||
|
||||
function normalizeApprovalForwardingMode(
|
||||
mode: ApprovalForwardingConfig["mode"] | undefined,
|
||||
): ApprovalForwardingMode {
|
||||
return mode ?? "session";
|
||||
}
|
||||
|
||||
function approvalModeIncludesSession(mode: ApprovalForwardingMode): boolean {
|
||||
return mode === "session" || mode === "both";
|
||||
}
|
||||
|
||||
function approvalModeIncludesTargets(mode: ApprovalForwardingMode): boolean {
|
||||
return mode === "targets" || mode === "both";
|
||||
}
|
||||
|
||||
function matchesSignalApprovalReactionFilters(params: {
|
||||
config: ApprovalForwardingConfig;
|
||||
route: Pick<SignalApprovalReactionRoute, "agentId" | "sessionKey">;
|
||||
}): boolean {
|
||||
return matchesApprovalRequestFilters({
|
||||
request: {
|
||||
agentId: params.route.agentId,
|
||||
sessionKey: params.route.sessionKey,
|
||||
},
|
||||
agentFilter: params.config.agentFilter,
|
||||
sessionFilter: params.config.sessionFilter,
|
||||
fallbackAgentIdFromSessionKey: true,
|
||||
});
|
||||
}
|
||||
|
||||
function targetAccountMatches(params: {
|
||||
routeAccountId?: string | null;
|
||||
configuredAccountId?: string | null;
|
||||
}): boolean {
|
||||
const configuredAccountId = normalizeOptionalString(params.configuredAccountId);
|
||||
if (!configuredAccountId) {
|
||||
return true;
|
||||
}
|
||||
const routeAccountId = normalizeOptionalString(params.routeAccountId);
|
||||
return Boolean(
|
||||
routeAccountId &&
|
||||
normalizeAccountId(routeAccountId) === normalizeAccountId(configuredAccountId),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSignalApprovalRouteTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
}): string | null {
|
||||
try {
|
||||
return (
|
||||
resolveSignalTarget({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
input: params.to,
|
||||
})?.to ??
|
||||
normalizeSignalMessagingTarget(params.to) ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasMatchingSignalApprovalReactionTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
config: ApprovalForwardingConfig;
|
||||
route: Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }>;
|
||||
}): boolean {
|
||||
return (params.config.targets ?? []).some((target) => {
|
||||
if (normalizeLowercaseStringOrEmpty(target.channel) !== "signal") {
|
||||
return false;
|
||||
}
|
||||
const configuredTo = resolveSignalApprovalRouteTarget({
|
||||
cfg: params.cfg,
|
||||
accountId: target.accountId ?? params.route.accountId,
|
||||
to: target.to,
|
||||
});
|
||||
if (!configuredTo || configuredTo !== params.route.to) {
|
||||
return false;
|
||||
}
|
||||
return targetAccountMatches({
|
||||
routeAccountId: params.route.accountId,
|
||||
configuredAccountId: target.accountId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isSignalApprovalReactionRouteStillEnabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
target: Pick<SignalApprovalReactionTarget, "approvalKind" | "route">;
|
||||
}): boolean {
|
||||
const config = resolveApprovalForwardingConfig({
|
||||
cfg: params.cfg,
|
||||
approvalKind: params.target.approvalKind,
|
||||
});
|
||||
if (!config?.enabled) {
|
||||
return false;
|
||||
}
|
||||
const mode = normalizeApprovalForwardingMode(config.mode);
|
||||
if (params.target.route.deliveryMode === "target") {
|
||||
return (
|
||||
approvalModeIncludesTargets(mode) &&
|
||||
matchesSignalApprovalReactionFilters({ config, route: params.target.route }) &&
|
||||
hasMatchingSignalApprovalReactionTarget({
|
||||
cfg: params.cfg,
|
||||
config,
|
||||
route: params.target.route,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!approvalModeIncludesSession(mode)) {
|
||||
return false;
|
||||
}
|
||||
return matchesSignalApprovalReactionFilters({ config, route: params.target.route });
|
||||
}
|
||||
|
||||
export function resolveSignalApprovalConversationKey(to: string): string | null {
|
||||
return normalizeSignalMessagingTarget(to) ?? null;
|
||||
}
|
||||
|
||||
function resolveSignalApprovalConversationKeyForDeliveredTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
}): string | null {
|
||||
try {
|
||||
return (
|
||||
resolveSignalTarget({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
input: params.to,
|
||||
})?.to ?? resolveSignalApprovalConversationKey(params.to)
|
||||
);
|
||||
} catch {
|
||||
return resolveSignalApprovalConversationKey(params.to);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSignalApprovalTargetAuthorKey(value: string): string | null {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const withoutSignalPrefix = normalized.replace(/^signal:/i, "").trim();
|
||||
const lower = normalizeLowercaseStringOrEmpty(withoutSignalPrefix);
|
||||
if (lower.startsWith("uuid:")) {
|
||||
const uuid = withoutSignalPrefix.slice("uuid:".length).trim().toLowerCase();
|
||||
return uuid ? `uuid:${uuid}` : null;
|
||||
}
|
||||
if (looksLikeUuid(withoutSignalPrefix)) {
|
||||
return `uuid:${withoutSignalPrefix.toLowerCase()}`;
|
||||
}
|
||||
return normalizeE164(withoutSignalPrefix);
|
||||
}
|
||||
|
||||
export function resolveSignalApprovalTargetAuthorKeys(params: {
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
}): string[] {
|
||||
const targetAuthorUuid = normalizeOptionalString(params.targetAuthorUuid);
|
||||
const keys = [
|
||||
targetAuthorUuid
|
||||
? `uuid:${targetAuthorUuid
|
||||
.replace(/^uuid:/i, "")
|
||||
.trim()
|
||||
.toLowerCase()}`
|
||||
: null,
|
||||
params.targetAuthor ? normalizeSignalApprovalTargetAuthorKey(params.targetAuthor) : null,
|
||||
].filter((key): key is string => Boolean(key));
|
||||
return Array.from(new Set(keys));
|
||||
}
|
||||
|
||||
function buildReactionTargetKey(params: {
|
||||
accountId: string;
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
}) {
|
||||
const accountId = params.accountId.trim();
|
||||
const conversationKey = params.conversationKey.trim();
|
||||
const messageId = params.messageId.trim();
|
||||
if (!accountId || !conversationKey || !messageId || messageId === "unknown") {
|
||||
return null;
|
||||
}
|
||||
return `${accountId}:${conversationKey}:${messageId}`;
|
||||
}
|
||||
|
||||
function reportPersistentApprovalReactionError(error: unknown): void {
|
||||
try {
|
||||
getOptionalSignalRuntime()
|
||||
?.logging.getChildLogger({ plugin: "signal", feature: "approval-reaction-state" })
|
||||
.warn("Signal persistent approval reaction state failed", { error: String(error) });
|
||||
} catch {
|
||||
// Best effort only: persistent state must never break Signal reactions.
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedTarget(target: unknown): SignalApprovalReactionTarget | null {
|
||||
const value = target as Partial<SignalApprovalReactionTarget> | null | undefined;
|
||||
if (
|
||||
!value ||
|
||||
typeof value.approvalId !== "string" ||
|
||||
(value.approvalKind !== "exec" && value.approvalKind !== "plugin") ||
|
||||
!value.route ||
|
||||
(value.route.deliveryMode !== "session" && value.route.deliveryMode !== "target") ||
|
||||
!Array.isArray(value.targetAuthorKeys) ||
|
||||
!Array.isArray(value.allowedDecisions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const targetRouteTo =
|
||||
value.route.deliveryMode === "target" && typeof value.route.to === "string"
|
||||
? normalizeSignalMessagingTarget(value.route.to)
|
||||
: null;
|
||||
if (value.route.deliveryMode === "target" && !targetRouteTo) {
|
||||
return null;
|
||||
}
|
||||
const route: SignalApprovalReactionRoute =
|
||||
value.route.deliveryMode === "target"
|
||||
? {
|
||||
deliveryMode: "target",
|
||||
to: targetRouteTo!,
|
||||
...(typeof value.route.accountId === "string"
|
||||
? { accountId: value.route.accountId }
|
||||
: {}),
|
||||
...(typeof value.route.agentId === "string" ? { agentId: value.route.agentId } : {}),
|
||||
...(typeof value.route.sessionKey === "string"
|
||||
? { sessionKey: value.route.sessionKey }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
deliveryMode: "session",
|
||||
...(typeof value.route.agentId === "string" ? { agentId: value.route.agentId } : {}),
|
||||
...(typeof value.route.sessionKey === "string"
|
||||
? { sessionKey: value.route.sessionKey }
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
approvalId: value.approvalId,
|
||||
approvalKind: value.approvalKind,
|
||||
allowedDecisions: value.allowedDecisions,
|
||||
targetAuthorKeys: value.targetAuthorKeys,
|
||||
route,
|
||||
};
|
||||
}
|
||||
|
||||
export function listSignalApprovalReactionBindings(
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[],
|
||||
): SignalApprovalReactionBinding[] {
|
||||
return listApprovalReactionBindings({ allowedDecisions });
|
||||
}
|
||||
|
||||
export function buildSignalApprovalReactionHint(
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[],
|
||||
): string | null {
|
||||
return buildApprovalReactionHint({ allowedDecisions });
|
||||
}
|
||||
|
||||
export function addSignalApprovalReactionHintToText(params: {
|
||||
text: string;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
}): string {
|
||||
return addApprovalReactionHintToText(params);
|
||||
}
|
||||
|
||||
function buildTargetRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
approvalId: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }> | null {
|
||||
const to = resolveSignalApprovalRouteTarget({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
});
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
const route: Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }> = {
|
||||
deliveryMode: "target",
|
||||
to,
|
||||
...(normalizeOptionalString(params.accountId)
|
||||
? { accountId: normalizeOptionalString(params.accountId) }
|
||||
: {}),
|
||||
...(normalizeOptionalString(params.agentId)
|
||||
? { agentId: normalizeOptionalString(params.agentId) }
|
||||
: {}),
|
||||
...(normalizeOptionalString(params.sessionKey)
|
||||
? { sessionKey: normalizeOptionalString(params.sessionKey) }
|
||||
: {}),
|
||||
};
|
||||
return isSignalApprovalReactionRouteStillEnabled({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(params.approvalId),
|
||||
route,
|
||||
},
|
||||
})
|
||||
? route
|
||||
: null;
|
||||
}
|
||||
|
||||
export function hasSignalApprovalReactionApprovers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
return getSignalApprovalApprovers(params).length > 0;
|
||||
}
|
||||
|
||||
export function registerSignalApprovalReactionTarget(params: {
|
||||
accountId: string;
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
approvalId: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
targetAuthorKeys: readonly string[];
|
||||
route: SignalApprovalReactionRoute;
|
||||
routeAllowed: boolean;
|
||||
ttlMs?: number;
|
||||
}): SignalApprovalReactionTarget | null {
|
||||
const key = buildReactionTargetKey(params);
|
||||
const approvalId = params.approvalId.trim();
|
||||
const targetAuthorKeys = Array.from(
|
||||
new Set(
|
||||
params.targetAuthorKeys
|
||||
.map((entry) => normalizeSignalApprovalTargetAuthorKey(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
),
|
||||
);
|
||||
const allowedDecisions = listSignalApprovalReactionBindings(params.allowedDecisions).map(
|
||||
(binding) => binding.decision,
|
||||
);
|
||||
if (!params.routeAllowed || !key || !approvalId || allowedDecisions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (targetAuthorKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const route =
|
||||
params.route.deliveryMode === "target"
|
||||
? ({
|
||||
deliveryMode: "target",
|
||||
to: params.route.to,
|
||||
...(normalizeOptionalString(params.route.accountId)
|
||||
? { accountId: normalizeOptionalString(params.route.accountId) }
|
||||
: {}),
|
||||
...(normalizeOptionalString(params.route.agentId)
|
||||
? { agentId: normalizeOptionalString(params.route.agentId) }
|
||||
: {}),
|
||||
...(normalizeOptionalString(params.route.sessionKey)
|
||||
? { sessionKey: normalizeOptionalString(params.route.sessionKey) }
|
||||
: {}),
|
||||
} satisfies SignalApprovalReactionRoute)
|
||||
: ({
|
||||
deliveryMode: "session",
|
||||
...(normalizeOptionalString(params.route.agentId)
|
||||
? { agentId: normalizeOptionalString(params.route.agentId) }
|
||||
: {}),
|
||||
...(normalizeOptionalString(params.route.sessionKey)
|
||||
? { sessionKey: normalizeOptionalString(params.route.sessionKey) }
|
||||
: {}),
|
||||
} satisfies SignalApprovalReactionRoute);
|
||||
const target: SignalApprovalReactionTarget = {
|
||||
approvalId,
|
||||
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(approvalId),
|
||||
allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
};
|
||||
signalApprovalReactionTargets.register(key, target, { ttlMs: params.ttlMs });
|
||||
return target;
|
||||
}
|
||||
|
||||
export function addSignalApprovalReactionHintToStructuredPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
payload: ReplyPayload;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
}): ReplyPayload | null {
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return null;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
agentId: metadata.agentId,
|
||||
sessionKey: metadata.sessionKey,
|
||||
});
|
||||
if (!route || !params.payload.text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...params.payload,
|
||||
text: addSignalApprovalReactionHintToText({
|
||||
text: params.payload.text,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readSignalDeliveryVisibleText(result: SignalApprovalDeliveryResult): string | null {
|
||||
const meta = result.meta;
|
||||
const visibleText = meta?.signalVisibleText ?? meta?.visibleText;
|
||||
return typeof visibleText === "string" ? visibleText : null;
|
||||
}
|
||||
|
||||
function listDeliveredSignalMessageIdsWithVisibleHint(params: {
|
||||
payload: ReplyPayload;
|
||||
results: readonly SignalApprovalDeliveryResult[];
|
||||
}): string[] {
|
||||
const signalResults = params.results.filter(
|
||||
(result) => !result.channel || normalizeLowercaseStringOrEmpty(result.channel) === "signal",
|
||||
);
|
||||
const resultsWithVisibleText = signalResults.filter(
|
||||
(result) => readSignalDeliveryVisibleText(result) !== null,
|
||||
);
|
||||
const candidates = resultsWithVisibleText.length > 0 ? resultsWithVisibleText : signalResults;
|
||||
if (resultsWithVisibleText.length === 0 && candidates.length !== 1) {
|
||||
return [];
|
||||
}
|
||||
const ids = candidates
|
||||
.filter((result) =>
|
||||
resultsWithVisibleText.length > 0
|
||||
? hasApprovalReactionHintText(readSignalDeliveryVisibleText(result))
|
||||
: hasApprovalReactionHintText(params.payload.text),
|
||||
)
|
||||
.map((result) => normalizeOptionalString(result.messageId))
|
||||
.filter((messageId): messageId is string => Boolean(messageId && messageId !== "unknown"));
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
export function registerSignalApprovalReactionTargetForDeliveredPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
target: SignalApprovalDeliveryTarget;
|
||||
payload: ReplyPayload;
|
||||
results: readonly SignalApprovalDeliveryResult[];
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
ttlMs?: number;
|
||||
}): boolean {
|
||||
if (normalizeLowercaseStringOrEmpty(params.target.channel) !== "signal") {
|
||||
return false;
|
||||
}
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hasApprovalReactionHintText(params.payload.text)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.target.accountId })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const conversationKey = resolveSignalApprovalConversationKeyForDeliveredTarget({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId,
|
||||
to: params.target.to,
|
||||
});
|
||||
if (!conversationKey) {
|
||||
return false;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId,
|
||||
to: params.target.to,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
agentId: metadata.agentId,
|
||||
sessionKey: metadata.sessionKey,
|
||||
});
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
|
||||
if (targetAuthorKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let registered = false;
|
||||
for (const messageId of listDeliveredSignalMessageIdsWithVisibleHint({
|
||||
payload: params.payload,
|
||||
results: params.results,
|
||||
})) {
|
||||
registered =
|
||||
Boolean(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: normalizeAccountId(params.target.accountId ?? undefined),
|
||||
conversationKey,
|
||||
messageId,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
routeAllowed: true,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
) || registered;
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
|
||||
export function unregisterSignalApprovalReactionTarget(params: {
|
||||
accountId: string;
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
}): void {
|
||||
const key = buildReactionTargetKey(params);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
signalApprovalReactionTargets.delete(key);
|
||||
}
|
||||
|
||||
function resolveTarget(params: {
|
||||
target: SignalApprovalReactionTarget | null | undefined;
|
||||
reactionKey: string;
|
||||
targetAuthorKeys: readonly string[];
|
||||
}): SignalApprovalReactionResolution | null {
|
||||
const target = params.target;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
params.targetAuthorKeys.length === 0 ||
|
||||
!params.targetAuthorKeys.some((key) => target.targetAuthorKeys.includes(key))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveApprovalReactionTarget<SignalApprovalReactionRoute>({
|
||||
target,
|
||||
reactionKey: params.reactionKey,
|
||||
});
|
||||
if (!resolved?.route) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
approvalId: resolved.approvalId,
|
||||
approvalKind: resolved.approvalKind,
|
||||
decision: resolved.decision,
|
||||
route: resolved.route,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSignalApprovalReactionTargetWithPersistence(params: {
|
||||
accountId: string;
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
reactionKey: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
}): Promise<SignalApprovalReactionResolution | null> {
|
||||
const key = buildReactionTargetKey(params);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
|
||||
if (targetAuthorKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return resolveTarget({
|
||||
target: await signalApprovalReactionTargets.lookup(key),
|
||||
reactionKey: params.reactionKey,
|
||||
targetAuthorKeys,
|
||||
});
|
||||
}
|
||||
|
||||
export async function maybeResolveSignalApprovalReaction(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
reactionKey: string;
|
||||
actorId?: string | null;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
gatewayUrl?: string;
|
||||
logVerboseMessage?: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
const target = await resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: params.accountId,
|
||||
conversationKey: params.conversationKey,
|
||||
messageId: params.messageId,
|
||||
reactionKey: params.reactionKey,
|
||||
targetAuthor: params.targetAuthor,
|
||||
targetAuthorUuid: params.targetAuthorUuid,
|
||||
});
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSignalApprovalReactionRouteStillEnabled({ cfg: params.cfg, target })) {
|
||||
params.logVerboseMessage?.(
|
||||
`signal: approval reaction denied id=${target.approvalId}; approval route is no longer enabled`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const actorId = params.actorId?.trim();
|
||||
if (!actorId) {
|
||||
params.logVerboseMessage?.(
|
||||
`signal: approval reaction ignored for ${target.approvalId}; missing actor identity`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const approvers = getSignalApprovalApprovers({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (approvers.length === 0) {
|
||||
params.logVerboseMessage?.(
|
||||
`signal: approval reaction denied id=${target.approvalId}; reactions require explicit approvers`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const auth = signalApprovalAuth.authorizeActorAction({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
senderId: actorId,
|
||||
action: "approve",
|
||||
approvalKind: target.approvalKind,
|
||||
});
|
||||
if (!auth.authorized) {
|
||||
params.logVerboseMessage?.(
|
||||
`signal: approval reaction denied id=${target.approvalId} sender=${actorId}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const { isApprovalNotFoundError, resolveSignalApproval } = await loadApprovalResolver();
|
||||
try {
|
||||
await resolveSignalApproval({
|
||||
cfg: params.cfg,
|
||||
approvalId: target.approvalId,
|
||||
decision: target.decision,
|
||||
senderId: actorId,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
});
|
||||
params.logVerboseMessage?.(
|
||||
`signal: approval reaction resolved id=${target.approvalId} sender=${actorId} decision=${target.decision}`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isApprovalNotFoundError(error)) {
|
||||
unregisterSignalApprovalReactionTarget({
|
||||
accountId: params.accountId,
|
||||
conversationKey: params.conversationKey,
|
||||
messageId: params.messageId,
|
||||
});
|
||||
params.logVerboseMessage?.(
|
||||
`signal: approval reaction ignored for expired approval id=${target.approvalId} sender=${actorId}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
params.logVerboseMessage?.(
|
||||
`signal: approval reaction failed id=${target.approvalId} sender=${actorId}: ${String(error)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSignalApprovalReactionTargetsForTest(): void {
|
||||
signalApprovalReactionTargets.clearForTest();
|
||||
resolverRuntimeLoader.clear();
|
||||
}
|
||||
24
extensions/signal/src/approval-resolver.ts
Normal file
24
extensions/signal/src/approval-resolver.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Signal plugin module implements approval resolver behavior.
|
||||
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
|
||||
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
|
||||
|
||||
export { isApprovalNotFoundError };
|
||||
|
||||
export async function resolveSignalApproval(params: {
|
||||
cfg: OpenClawConfig;
|
||||
approvalId: string;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
senderId?: string | null;
|
||||
gatewayUrl?: string;
|
||||
}): Promise<void> {
|
||||
await resolveApprovalOverGateway({
|
||||
cfg: params.cfg,
|
||||
approvalId: params.approvalId,
|
||||
decision: params.decision,
|
||||
senderId: params.senderId,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
clientDisplayName: `Signal approval (${params.senderId?.trim() || "unknown"})`,
|
||||
});
|
||||
}
|
||||
6
extensions/signal/src/channel.runtime.ts
Normal file
6
extensions/signal/src/channel.runtime.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Signal plugin module implements channel behavior.
|
||||
import { signalSetupWizard as signalSetupWizardImpl } from "./setup-surface.js";
|
||||
|
||||
type SignalSetupWizard = typeof import("./setup-surface.js").signalSetupWizard;
|
||||
|
||||
export const signalSetupWizard: SignalSetupWizard = { ...signalSetupWizardImpl };
|
||||
12
extensions/signal/src/channel.setup.ts
Normal file
12
extensions/signal/src/channel.setup.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Signal plugin module implements channel.setup behavior.
|
||||
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,
|
||||
}),
|
||||
};
|
||||
630
extensions/signal/src/channel.ts
Normal file
630
extensions/signal/src/channel.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
// Signal plugin module implements channel behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-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/string-coerce-runtime";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
|
||||
import { listSignalAliasDirectoryEntries, resolveSignalTarget } from "./aliases.js";
|
||||
import {
|
||||
shouldSuppressLocalSignalExecApprovalPrompt,
|
||||
signalApprovalCapability,
|
||||
} from "./approval-native.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;
|
||||
|
||||
const loadSignalMonitorModule = createLazyRuntimeModule(() => import("./monitor.js"));
|
||||
|
||||
const loadSignalProbeModule = createLazyRuntimeModule(() => import("./probe.js"));
|
||||
|
||||
const loadSignalSendRuntime = createLazyRuntimeModule(() => import("./send.runtime.js"));
|
||||
|
||||
const loadSignalApprovalReactionsModule = createLazyRuntimeModule(
|
||||
() => import("./approval-reactions.js"),
|
||||
);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function resolveSignalSendTarget(params: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
accountId?: string;
|
||||
to: string;
|
||||
}) {
|
||||
return (
|
||||
resolveSignalTarget({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
input: params.to,
|
||||
})?.to ?? params.to.trim()
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const to = resolveSignalSendTarget(params);
|
||||
return await send(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,
|
||||
});
|
||||
}
|
||||
|
||||
type SignalMessageContextExtras = {
|
||||
deps?: { [channelId: string]: unknown };
|
||||
};
|
||||
|
||||
function attachSignalVisibleText<T extends object>(result: T, visibleText: string) {
|
||||
const meta =
|
||||
"meta" in result && result.meta && typeof result.meta === "object"
|
||||
? (result.meta as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...result,
|
||||
meta: {
|
||||
...meta,
|
||||
signalVisibleText: visibleText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const signalMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "signal",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: async (ctx) =>
|
||||
await sendSignalOutbound({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: ctx.text,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
deps: (ctx as typeof ctx & SignalMessageContextExtras).deps,
|
||||
}),
|
||||
media: async (ctx) =>
|
||||
await sendSignalOutbound({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: ctx.text,
|
||||
mediaUrl: ctx.mediaUrl,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
mediaReadFile: ctx.mediaReadFile,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
deps: (ctx as typeof ctx & SignalMessageContextExtras).deps,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
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 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;
|
||||
resolvedTarget?: { to: string };
|
||||
}) {
|
||||
const resolved = resolveSignalOutboundTarget(params.resolvedTarget?.to ?? 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;
|
||||
onDeliveryResult?: Parameters<
|
||||
NonNullable<ChannelOutboundAdapter["sendFormattedText"]>
|
||||
>[0]["onDeliveryResult"];
|
||||
}) {
|
||||
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 to = resolveSignalSendTarget({
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
to: ctx.to,
|
||||
});
|
||||
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(to, chunk.text, {
|
||||
cfg: ctx.cfg,
|
||||
maxBytes,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: chunk.styles,
|
||||
});
|
||||
const deliveryResult = attachChannelToResult(
|
||||
"signal",
|
||||
attachSignalVisibleText(result, chunk.text),
|
||||
);
|
||||
results.push(deliveryResult);
|
||||
await ctx.onDeliveryResult?.(deliveryResult);
|
||||
}
|
||||
return 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 to = resolveSignalSendTarget({
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
to: ctx.to,
|
||||
});
|
||||
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(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", attachSignalVisibleText(result, formatted.text));
|
||||
}
|
||||
|
||||
async function registerDeliveredSignalApprovalPayloadForReactions(
|
||||
params: Parameters<NonNullable<ChannelOutboundAdapter["afterDeliverPayload"]>>[0],
|
||||
) {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId ?? undefined,
|
||||
});
|
||||
if (!account.config.account) {
|
||||
return;
|
||||
}
|
||||
const { registerSignalApprovalReactionTargetForDeliveredPayload } =
|
||||
await loadSignalApprovalReactionsModule();
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: params.target,
|
||||
payload: params.payload,
|
||||
results: params.results,
|
||||
targetAuthor: account.config.account,
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSignalApprovalPayloadForReactions(
|
||||
params: Parameters<NonNullable<ChannelOutboundAdapter["renderPresentation"]>>[0],
|
||||
) {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
});
|
||||
if (!account.config.account) {
|
||||
return null;
|
||||
}
|
||||
const { addSignalApprovalReactionHintToStructuredPayload } =
|
||||
await loadSignalApprovalReactionsModule();
|
||||
return addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
to: params.ctx.to,
|
||||
payload: params.payload,
|
||||
targetAuthor: account.config.account,
|
||||
});
|
||||
}
|
||||
|
||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
createChatChannelPlugin({
|
||||
base: {
|
||||
...createSignalPluginBase({
|
||||
setupWizard: signalSetupWizard,
|
||||
setup: signalSetupAdapter,
|
||||
}),
|
||||
actions: signalMessageActions,
|
||||
approvalCapability: signalApprovalCapability,
|
||||
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: {
|
||||
targetPrefixes: ["signal"],
|
||||
normalizeTarget: normalizeSignalMessagingTarget,
|
||||
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),
|
||||
resolveOutboundSessionRoute: (params) => resolveSignalOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeSignalTargetId,
|
||||
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
|
||||
resolveTarget: async ({ cfg, accountId, input }) => {
|
||||
let target: ReturnType<typeof resolveSignalTarget>;
|
||||
try {
|
||||
target = resolveSignalTarget({ cfg, accountId, input });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: target.to,
|
||||
kind: target.kind,
|
||||
display: target.source === "alias" ? target.alias : undefined,
|
||||
source: target.source === "alias" ? "directory" : "normalized",
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
listPeers: async ({ cfg, accountId, query, limit }) =>
|
||||
listSignalAliasDirectoryEntries({
|
||||
cfg,
|
||||
accountId,
|
||||
query,
|
||||
limit,
|
||||
kind: "user",
|
||||
}),
|
||||
listGroups: async ({ cfg, accountId, query, limit }) =>
|
||||
listSignalAliasDirectoryEntries({
|
||||
cfg,
|
||||
accountId,
|
||||
query,
|
||||
limit,
|
||||
kind: "group",
|
||||
}),
|
||||
},
|
||||
heartbeat: {
|
||||
sendTyping: async ({ cfg, to, accountId }) => {
|
||||
await (
|
||||
await loadSignalSendRuntime()
|
||||
).sendTypingSignal(to, {
|
||||
cfg,
|
||||
...(accountId ? { accountId } : {}),
|
||||
});
|
||||
},
|
||||
clearTyping: async ({ cfg, to, accountId }) => {
|
||||
await (
|
||||
await loadSignalSendRuntime()
|
||||
).sendTypingSignal(to, {
|
||||
cfg,
|
||||
...(accountId ? { accountId } : {}),
|
||||
stop: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
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, {
|
||||
apiMode: account.config?.apiMode ?? "auto",
|
||||
});
|
||||
},
|
||||
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,
|
||||
channelRuntime: ctx.channelRuntime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
mediaMaxMb: account.config.mediaMaxMb,
|
||||
});
|
||||
},
|
||||
},
|
||||
message: signalMessageAdapter,
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
idLabel: "signalNumber",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^signal:/i),
|
||||
notify: async ({ cfg, id, message }) => {
|
||||
await (
|
||||
await loadSignalSendRuntime()
|
||||
).sendMessageSignal(id, message, {
|
||||
cfg,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
security: signalSecurityAdapter,
|
||||
outbound: {
|
||||
base: {
|
||||
deliveryMode: "direct",
|
||||
resolveTarget: ({ cfg, to, accountId }) => {
|
||||
const raw = to?.trim();
|
||||
if (!raw) {
|
||||
return { ok: false, error: new Error("Signal target is required") };
|
||||
}
|
||||
let target: ReturnType<typeof resolveSignalTarget>;
|
||||
try {
|
||||
target = resolveSignalTarget({
|
||||
cfg: cfg ?? {},
|
||||
accountId,
|
||||
input: raw,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`Unknown Signal alias or target "${raw}". Configure channels.signal.aliases.${raw.replace(/^signal:/i, "")} or use E.164, uuid:<id>, username:<name>, or group:<id>.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: target.to };
|
||||
},
|
||||
chunker: chunkText,
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),
|
||||
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) =>
|
||||
shouldSuppressLocalSignalExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId,
|
||||
payload,
|
||||
hint,
|
||||
}),
|
||||
afterDeliverPayload: async (params) =>
|
||||
await registerDeliveredSignalApprovalPayloadForReactions(params),
|
||||
renderPresentation: async (params) => await renderSignalApprovalPayloadForReactions(params),
|
||||
sendFormattedText: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
deps,
|
||||
abortSignal,
|
||||
onDeliveryResult,
|
||||
}) =>
|
||||
await sendFormattedSignalText({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
deps,
|
||||
abortSignal,
|
||||
onDeliveryResult,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
743
extensions/signal/src/client-adapter.test.ts
Normal file
743
extensions/signal/src/client-adapter.test.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
// Signal tests cover client adapter plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
signalRpcRequest as signalRpcRequestImpl,
|
||||
detectSignalApiMode,
|
||||
signalCheck as signalCheckImpl,
|
||||
streamSignalEvents as streamSignalEventsImpl,
|
||||
fetchAttachment as fetchAttachmentImpl,
|
||||
type SignalApiMode,
|
||||
} from "./client-adapter.js";
|
||||
import * as containerClientModule from "./client-container.js";
|
||||
import * as nativeClientModule from "./client.js";
|
||||
|
||||
const mockNativeCheck = vi.fn();
|
||||
const mockNativeRpcRequest = vi.fn();
|
||||
const mockNativeStreamEvents = vi.fn();
|
||||
const mockContainerCheck = vi.fn();
|
||||
const mockContainerRpcRequest = vi.fn();
|
||||
const mockContainerFetchAttachment = vi.fn();
|
||||
const mockStreamContainerEvents = vi.fn();
|
||||
let currentApiMode: SignalApiMode = "auto";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(nativeClientModule, "signalCheck").mockImplementation(mockNativeCheck as any);
|
||||
vi.spyOn(nativeClientModule, "signalRpcRequest").mockImplementation(mockNativeRpcRequest as any);
|
||||
vi.spyOn(nativeClientModule, "streamSignalEvents").mockImplementation(
|
||||
mockNativeStreamEvents as any,
|
||||
);
|
||||
vi.spyOn(containerClientModule, "containerCheck").mockImplementation(mockContainerCheck as any);
|
||||
vi.spyOn(containerClientModule, "containerRpcRequest").mockImplementation(
|
||||
mockContainerRpcRequest as any,
|
||||
);
|
||||
vi.spyOn(containerClientModule, "containerFetchAttachment").mockImplementation(
|
||||
mockContainerFetchAttachment as any,
|
||||
);
|
||||
vi.spyOn(containerClientModule, "streamContainerEvents").mockImplementation(
|
||||
mockStreamContainerEvents as any,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function setApiMode(mode: SignalApiMode) {
|
||||
currentApiMode = mode;
|
||||
}
|
||||
|
||||
function signalRpcRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
opts: Parameters<typeof signalRpcRequestImpl>[2],
|
||||
) {
|
||||
return signalRpcRequestImpl<T>(method, params, { ...opts, apiMode: currentApiMode });
|
||||
}
|
||||
|
||||
function signalCheck(baseUrl: string, timeoutMs?: number) {
|
||||
return signalCheckImpl(baseUrl, timeoutMs, { apiMode: currentApiMode });
|
||||
}
|
||||
|
||||
function streamSignalEvents(params: Parameters<typeof streamSignalEventsImpl>[0]) {
|
||||
return streamSignalEventsImpl({ ...params, apiMode: currentApiMode });
|
||||
}
|
||||
|
||||
function fetchAttachment(params: Parameters<typeof fetchAttachmentImpl>[0]) {
|
||||
return fetchAttachmentImpl({ ...params, apiMode: currentApiMode });
|
||||
}
|
||||
|
||||
type MockCalls = {
|
||||
mock: { calls: unknown[][] };
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
expect(isRecord(value), `${label} should be an object`).toBe(true);
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`${label} should be an object`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expectFields(record: Record<string, unknown>, expected: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(record[key], key).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
function requireMockCall(mock: MockCalls, label: string, index = 0): unknown[] {
|
||||
const call = mock.mock.calls.at(index);
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call ${index}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function expectRpcCall(params: {
|
||||
mock: MockCalls;
|
||||
method: string;
|
||||
rpcParams?: Record<string, unknown>;
|
||||
options?: Record<string, unknown>;
|
||||
}) {
|
||||
expect(params.mock.mock.calls).toHaveLength(1);
|
||||
const [method, rpcParams, options] = requireMockCall(params.mock, "rpc");
|
||||
expect(method).toBe(params.method);
|
||||
if (params.rpcParams) {
|
||||
expectFields(requireRecord(rpcParams, "rpc params"), params.rpcParams);
|
||||
} else if (rpcParams === undefined) {
|
||||
throw new Error("expected rpc params argument");
|
||||
}
|
||||
if (params.options) {
|
||||
expectFields(requireRecord(options, "rpc options"), params.options);
|
||||
} else if (options === undefined) {
|
||||
throw new Error("expected rpc options argument");
|
||||
}
|
||||
}
|
||||
|
||||
function expectSingleObjectCall(mock: MockCalls, expected: Record<string, unknown>) {
|
||||
expect(mock.mock.calls).toHaveLength(1);
|
||||
const [payload] = requireMockCall(mock, "single object");
|
||||
expectFields(requireRecord(payload, "call payload"), expected);
|
||||
}
|
||||
|
||||
function expectContainerFetchCall(expected: Record<string, unknown>) {
|
||||
expect(mockContainerFetchAttachment.mock.calls).toHaveLength(1);
|
||||
const [attachmentId, options] = requireMockCall(mockContainerFetchAttachment, "container fetch");
|
||||
expect(attachmentId).toBe("attachment-123");
|
||||
expectFields(requireRecord(options, "container fetch options"), expected);
|
||||
}
|
||||
|
||||
describe("detectSignalApiMode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setApiMode("native");
|
||||
});
|
||||
|
||||
it("returns native when native endpoint responds", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
const result = await detectSignalApiMode("http://localhost:8080");
|
||||
expect(result).toBe("native");
|
||||
});
|
||||
|
||||
it("returns container when only container endpoint responds", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await detectSignalApiMode("http://localhost:8080");
|
||||
expect(result).toBe("container");
|
||||
});
|
||||
|
||||
it("prefers native when both endpoints respond", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await detectSignalApiMode("http://localhost:8080");
|
||||
expect(result).toBe("native");
|
||||
});
|
||||
|
||||
it("prefers native even when the container probe resolves first", async () => {
|
||||
mockNativeCheck.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ ok: true, status: 200 }), 1);
|
||||
}),
|
||||
);
|
||||
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await detectSignalApiMode("http://localhost:8080");
|
||||
expect(result).toBe("native");
|
||||
});
|
||||
|
||||
it("returns container after the native preference grace when native does not respond", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockNativeCheck.mockImplementation(() => new Promise(() => {}));
|
||||
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = detectSignalApiMode("http://localhost:8080");
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await expect(result).resolves.toBe("container");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws error when neither endpoint responds", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" });
|
||||
|
||||
await expect(detectSignalApiMode("http://localhost:8080")).rejects.toThrow(
|
||||
"Signal API not reachable at http://localhost:8080",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles exceptions from check functions", async () => {
|
||||
mockNativeCheck.mockRejectedValue(new Error("Network error"));
|
||||
mockContainerCheck.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(detectSignalApiMode("http://localhost:8080")).rejects.toThrow(
|
||||
"Signal API not reachable",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects timeout parameter", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false });
|
||||
|
||||
await detectSignalApiMode("http://localhost:8080", 5000);
|
||||
expect(mockNativeCheck).toHaveBeenCalledWith("http://localhost:8080", 5000);
|
||||
expect(mockContainerCheck).toHaveBeenCalledWith("http://localhost:8080", 5000);
|
||||
});
|
||||
|
||||
it("requires a working container receive WebSocket when requested", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: true, status: 101 });
|
||||
|
||||
const result = await detectSignalApiMode("http://localhost:8080", 5000, {
|
||||
account: "+14259798283",
|
||||
requireContainerReceive: true,
|
||||
});
|
||||
|
||||
expect(result).toBe("container");
|
||||
expect(mockContainerCheck).toHaveBeenCalledWith("http://localhost:8080", 5000, "+14259798283");
|
||||
});
|
||||
|
||||
it("does not select container receive mode without an account", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
await expect(
|
||||
detectSignalApiMode("http://localhost:8080", 5000, {
|
||||
requireContainerReceive: true,
|
||||
}),
|
||||
).rejects.toThrow("Signal API not reachable");
|
||||
|
||||
expect(mockContainerCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRpcRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setApiMode("native");
|
||||
});
|
||||
|
||||
it("routes to native JSON-RPC for native mode", async () => {
|
||||
mockNativeRpcRequest.mockResolvedValue({ timestamp: 1700000000000 });
|
||||
|
||||
const result = await signalRpcRequest(
|
||||
"send",
|
||||
{ message: "Hello", account: "+14259798283", recipient: ["+15550001111"] },
|
||||
{ baseUrl: "http://localhost:8080" },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ timestamp: 1700000000000 });
|
||||
expectRpcCall({
|
||||
mock: mockNativeRpcRequest,
|
||||
method: "send",
|
||||
rpcParams: { message: "Hello" },
|
||||
options: { baseUrl: "http://localhost:8080" },
|
||||
});
|
||||
expect(mockContainerRpcRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes to container RPC for container mode", async () => {
|
||||
setApiMode("container");
|
||||
mockContainerRpcRequest.mockResolvedValue({ timestamp: 1700000000000 });
|
||||
|
||||
const result = await signalRpcRequest(
|
||||
"send",
|
||||
{ message: "Hello", account: "+14259798283", recipient: ["+15550001111"] },
|
||||
{ baseUrl: "http://localhost:8080" },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ timestamp: 1700000000000 });
|
||||
expectRpcCall({
|
||||
mock: mockContainerRpcRequest,
|
||||
method: "send",
|
||||
rpcParams: { message: "Hello" },
|
||||
options: { baseUrl: "http://localhost:8080" },
|
||||
});
|
||||
expect(mockNativeRpcRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses explicit apiMode from the active config", async () => {
|
||||
setApiMode("native");
|
||||
mockContainerRpcRequest.mockResolvedValue({ timestamp: 1700000000000 });
|
||||
|
||||
const result = await signalRpcRequestImpl(
|
||||
"send",
|
||||
{ message: "Hello", account: "+14259798283", recipient: ["+15550001111"] },
|
||||
{ baseUrl: "http://localhost:8080", apiMode: "container" },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ timestamp: 1700000000000 });
|
||||
expect(mockContainerRpcRequest).toHaveBeenCalled();
|
||||
expect(mockNativeRpcRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes all RPC methods through to native", async () => {
|
||||
mockNativeRpcRequest.mockResolvedValue({});
|
||||
|
||||
await signalRpcRequest(
|
||||
"sendTyping",
|
||||
{ account: "+1", recipient: ["+2"] },
|
||||
{ baseUrl: "http://localhost:8080" },
|
||||
);
|
||||
expectRpcCall({ mock: mockNativeRpcRequest, method: "sendTyping" });
|
||||
});
|
||||
|
||||
it("passes all RPC methods through to container", async () => {
|
||||
setApiMode("container");
|
||||
mockContainerRpcRequest.mockResolvedValue({});
|
||||
|
||||
await signalRpcRequest(
|
||||
"sendReceipt",
|
||||
{ account: "+1", recipient: ["+2"] },
|
||||
{ baseUrl: "http://localhost:8080" },
|
||||
);
|
||||
expectRpcCall({ mock: mockContainerRpcRequest, method: "sendReceipt" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalCheck", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setApiMode("native");
|
||||
});
|
||||
|
||||
it("uses native check for native mode", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await signalCheck("http://localhost:8080");
|
||||
|
||||
expect(result).toEqual({ ok: true, status: 200 });
|
||||
expect(mockNativeCheck).toHaveBeenCalledWith("http://localhost:8080", 10000);
|
||||
expect(mockContainerCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses container check for container mode", async () => {
|
||||
setApiMode("container");
|
||||
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await signalCheck("http://localhost:8080");
|
||||
|
||||
expect(result).toEqual({ ok: true, status: 200 });
|
||||
expect(mockContainerCheck).toHaveBeenCalledWith("http://localhost:8080", 10000);
|
||||
expect(mockNativeCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects timeout parameter", async () => {
|
||||
mockNativeCheck.mockResolvedValue({ ok: true });
|
||||
|
||||
await signalCheck("http://localhost:8080", 5000);
|
||||
|
||||
expect(mockNativeCheck).toHaveBeenCalledWith("http://localhost:8080", 5000);
|
||||
});
|
||||
|
||||
it("uses the caller timeout for auto detection", async () => {
|
||||
setApiMode("auto");
|
||||
mockNativeCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" });
|
||||
|
||||
await signalCheck("http://auto-timeout.local:8080", 1000);
|
||||
|
||||
expect(mockNativeCheck).toHaveBeenCalledWith("http://auto-timeout.local:8080", 1000);
|
||||
expect(mockContainerCheck).toHaveBeenCalledWith("http://auto-timeout.local:8080", 1000);
|
||||
});
|
||||
|
||||
it("returns a retryable failure when auto detection is not ready", async () => {
|
||||
setApiMode("auto");
|
||||
mockNativeCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" });
|
||||
|
||||
await expect(signalCheck("http://localhost:8080")).resolves.toEqual({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: "Signal API not reachable at http://localhost:8080",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops cached auto mode when the current clock is not a valid date timestamp", async () => {
|
||||
setApiMode("auto");
|
||||
vi.spyOn(Date, "now").mockReturnValueOnce(1_700_000_000_000).mockReturnValueOnce(Number.NaN);
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
await expect(signalCheck("http://auto-invalid-clock.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
await expect(signalCheck("http://auto-invalid-clock.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
expect(mockNativeCheck).toHaveBeenCalledTimes(4);
|
||||
expect(mockContainerCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache auto mode when the expiry timestamp would exceed the valid date range", async () => {
|
||||
setApiMode("auto");
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
await expect(signalCheck("http://auto-overflow-clock.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
await expect(signalCheck("http://auto-overflow-clock.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
expect(mockNativeCheck).toHaveBeenCalledTimes(4);
|
||||
expect(mockContainerCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamSignalEvents", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setApiMode("native");
|
||||
});
|
||||
|
||||
it("uses native SSE for native mode", async () => {
|
||||
mockNativeStreamEvents.mockResolvedValue(undefined);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
await streamSignalEvents({
|
||||
baseUrl: "http://localhost:8080",
|
||||
account: "+14259798283",
|
||||
onEvent,
|
||||
});
|
||||
|
||||
expectSingleObjectCall(mockNativeStreamEvents, {
|
||||
baseUrl: "http://localhost:8080",
|
||||
account: "+14259798283",
|
||||
});
|
||||
expect(mockStreamContainerEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses container WebSocket for container mode", async () => {
|
||||
setApiMode("container");
|
||||
mockStreamContainerEvents.mockResolvedValue(undefined);
|
||||
|
||||
const onEvent = vi.fn();
|
||||
await streamSignalEvents({
|
||||
baseUrl: "http://localhost:8080",
|
||||
account: "+14259798283",
|
||||
onEvent,
|
||||
});
|
||||
|
||||
expectSingleObjectCall(mockStreamContainerEvents, {
|
||||
baseUrl: "http://localhost:8080",
|
||||
account: "+14259798283",
|
||||
});
|
||||
expect(mockNativeStreamEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes native SSE events through unchanged", async () => {
|
||||
const payload = { envelope: { sourceNumber: "+1555000111" } };
|
||||
mockNativeStreamEvents.mockImplementation(async (params) => {
|
||||
params.onEvent({ event: "receive", data: JSON.stringify(payload) });
|
||||
});
|
||||
|
||||
const events: unknown[] = [];
|
||||
await streamSignalEvents({
|
||||
baseUrl: "http://localhost:8080",
|
||||
onEvent: (evt) => events.push(evt),
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toEqual({ event: "receive", data: JSON.stringify(payload) });
|
||||
});
|
||||
|
||||
it("converts container events to SSE-like receive events", async () => {
|
||||
setApiMode("container");
|
||||
mockStreamContainerEvents.mockImplementation(async (params) => {
|
||||
params.onEvent({ envelope: { sourceNumber: "+1555000111" } });
|
||||
});
|
||||
|
||||
const events: unknown[] = [];
|
||||
await streamSignalEvents({
|
||||
baseUrl: "http://localhost:8080",
|
||||
onEvent: (evt) => events.push(evt),
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toEqual({
|
||||
event: "receive",
|
||||
data: JSON.stringify({ envelope: { sourceNumber: "+1555000111" } }),
|
||||
});
|
||||
});
|
||||
|
||||
it("passes abort signal to underlying stream", async () => {
|
||||
mockNativeStreamEvents.mockResolvedValue(undefined);
|
||||
|
||||
const abortController = new AbortController();
|
||||
await streamSignalEvents({
|
||||
baseUrl: "http://localhost:8080",
|
||||
abortSignal: abortController.signal,
|
||||
onEvent: vi.fn(),
|
||||
});
|
||||
|
||||
expectSingleObjectCall(mockNativeStreamEvents, {
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards timeout to native SSE stream", async () => {
|
||||
mockNativeStreamEvents.mockResolvedValue(undefined);
|
||||
|
||||
await streamSignalEvents({
|
||||
baseUrl: "http://localhost:8080",
|
||||
timeoutMs: 45000,
|
||||
onEvent: vi.fn(),
|
||||
});
|
||||
|
||||
expectSingleObjectCall(mockNativeStreamEvents, {
|
||||
timeoutMs: 45000,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a positive probe timeout while preserving zero stream timeout", async () => {
|
||||
setApiMode("auto");
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
mockNativeStreamEvents.mockResolvedValue(undefined);
|
||||
|
||||
await streamSignalEvents({
|
||||
baseUrl: "http://zero-timeout.local:8080",
|
||||
account: "+14259798283",
|
||||
timeoutMs: 0,
|
||||
onEvent: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mockNativeCheck).toHaveBeenCalledWith("http://zero-timeout.local:8080", 10000);
|
||||
expect(mockContainerCheck).toHaveBeenCalledWith(
|
||||
"http://zero-timeout.local:8080",
|
||||
10000,
|
||||
"+14259798283",
|
||||
);
|
||||
expectSingleObjectCall(mockNativeStreamEvents, {
|
||||
timeoutMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards timeout to container event stream", async () => {
|
||||
setApiMode("container");
|
||||
mockStreamContainerEvents.mockResolvedValue(undefined);
|
||||
|
||||
await streamSignalEvents({
|
||||
baseUrl: "http://localhost:8080",
|
||||
timeoutMs: 45000,
|
||||
onEvent: vi.fn(),
|
||||
});
|
||||
|
||||
expectSingleObjectCall(mockStreamContainerEvents, {
|
||||
timeoutMs: 45000,
|
||||
});
|
||||
});
|
||||
|
||||
it("revalidates an unvalidated cached container mode before streaming", async () => {
|
||||
setApiMode("auto");
|
||||
mockNativeCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
mockContainerCheck
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 200,
|
||||
error: "Signal container receive endpoint did not upgrade to WebSocket (HTTP 200)",
|
||||
});
|
||||
|
||||
await expect(signalCheck("http://auto-cache.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await expect(
|
||||
streamSignalEvents({
|
||||
baseUrl: "http://auto-cache.local:8080",
|
||||
account: "+14259798283",
|
||||
onEvent: vi.fn(),
|
||||
}),
|
||||
).rejects.toThrow("Signal API not reachable at http://auto-cache.local:8080");
|
||||
expect(mockStreamContainerEvents).not.toHaveBeenCalled();
|
||||
expect(mockContainerCheck).toHaveBeenLastCalledWith(
|
||||
"http://auto-cache.local:8080",
|
||||
10000,
|
||||
"+14259798283",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reuse a cached container mode for no-account receive streams", async () => {
|
||||
setApiMode("auto");
|
||||
mockNativeCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await expect(signalCheck("http://auto-cache-no-account.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await expect(
|
||||
streamSignalEvents({
|
||||
baseUrl: "http://auto-cache-no-account.local:8080",
|
||||
onEvent: vi.fn(),
|
||||
}),
|
||||
).rejects.toThrow("Signal API not reachable at http://auto-cache-no-account.local:8080");
|
||||
expect(mockStreamContainerEvents).not.toHaveBeenCalled();
|
||||
expect(mockContainerCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setApiMode("native");
|
||||
});
|
||||
|
||||
it("uses native JSON-RPC for native mode with sender", async () => {
|
||||
mockNativeRpcRequest.mockResolvedValue({ data: "base64data" });
|
||||
|
||||
const result = await fetchAttachment({
|
||||
baseUrl: "http://localhost:8080",
|
||||
account: "+14259798283",
|
||||
attachmentId: "attachment-123",
|
||||
sender: "+15550001111",
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expectRpcCall({
|
||||
mock: mockNativeRpcRequest,
|
||||
method: "getAttachment",
|
||||
rpcParams: {
|
||||
id: "attachment-123",
|
||||
account: "+14259798283",
|
||||
recipient: "+15550001111",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses container REST for container mode", async () => {
|
||||
setApiMode("container");
|
||||
const mockBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
mockContainerFetchAttachment.mockResolvedValue(mockBuffer);
|
||||
|
||||
const result = await fetchAttachment({
|
||||
baseUrl: "http://localhost:8080",
|
||||
attachmentId: "attachment-123",
|
||||
});
|
||||
|
||||
expect(result).toBe(mockBuffer);
|
||||
expectContainerFetchCall({ baseUrl: "http://localhost:8080" });
|
||||
});
|
||||
|
||||
it("returns null for native mode without sender or groupId", async () => {
|
||||
const result = await fetchAttachment({
|
||||
baseUrl: "http://localhost:8080",
|
||||
attachmentId: "attachment-123",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockNativeRpcRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses groupId when provided for native mode", async () => {
|
||||
mockNativeRpcRequest.mockResolvedValue({ data: "base64data" });
|
||||
|
||||
await fetchAttachment({
|
||||
baseUrl: "http://localhost:8080",
|
||||
attachmentId: "attachment-123",
|
||||
groupId: "group-123",
|
||||
});
|
||||
|
||||
expectRpcCall({
|
||||
mock: mockNativeRpcRequest,
|
||||
method: "getAttachment",
|
||||
rpcParams: { groupId: "group-123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when native RPC returns no data", async () => {
|
||||
mockNativeRpcRequest.mockResolvedValue({});
|
||||
|
||||
const result = await fetchAttachment({
|
||||
baseUrl: "http://localhost:8080",
|
||||
attachmentId: "attachment-123",
|
||||
sender: "+15550001111",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers groupId over sender when both provided", async () => {
|
||||
mockNativeRpcRequest.mockResolvedValue({ data: "base64data" });
|
||||
|
||||
await fetchAttachment({
|
||||
baseUrl: "http://localhost:8080",
|
||||
attachmentId: "attachment-123",
|
||||
sender: "+15550001111",
|
||||
groupId: "group-123",
|
||||
});
|
||||
|
||||
const callParams = requireMockCall(mockNativeRpcRequest, "native RPC")[1];
|
||||
expect(callParams).toHaveProperty("groupId", "group-123");
|
||||
expect(callParams).not.toHaveProperty("recipient");
|
||||
});
|
||||
|
||||
it("passes timeout to container fetch", async () => {
|
||||
setApiMode("container");
|
||||
mockContainerFetchAttachment.mockResolvedValue(Buffer.from([]));
|
||||
|
||||
await fetchAttachment({
|
||||
baseUrl: "http://localhost:8080",
|
||||
attachmentId: "attachment-123",
|
||||
timeoutMs: 60000,
|
||||
});
|
||||
|
||||
expectContainerFetchCall({ timeoutMs: 60000 });
|
||||
});
|
||||
|
||||
it("passes max response bytes to container fetch", async () => {
|
||||
setApiMode("container");
|
||||
mockContainerFetchAttachment.mockResolvedValue(Buffer.from([]));
|
||||
|
||||
await fetchAttachment({
|
||||
baseUrl: "http://localhost:8080",
|
||||
attachmentId: "attachment-123",
|
||||
maxResponseBytes: 4096,
|
||||
});
|
||||
|
||||
expectContainerFetchCall({ maxResponseBytes: 4096 });
|
||||
});
|
||||
});
|
||||
313
extensions/signal/src/client-adapter.ts
Normal file
313
extensions/signal/src/client-adapter.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Signal client adapter - unified interface for both native signal-cli and bbernhard container.
|
||||
*
|
||||
* This adapter provides a single API that routes to the appropriate implementation
|
||||
* based on the configured API mode. Exports mirror client.ts names so consumers
|
||||
* only need to change their import path.
|
||||
*/
|
||||
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
containerCheck,
|
||||
containerRpcRequest,
|
||||
streamContainerEvents,
|
||||
containerFetchAttachment,
|
||||
} from "./client-container.js";
|
||||
import type { SignalRpcOptions } from "./client.js";
|
||||
import {
|
||||
signalCheck as nativeCheck,
|
||||
signalRpcRequest as nativeRpcRequest,
|
||||
streamSignalEvents as nativeStreamEvents,
|
||||
} from "./client.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const MODE_CACHE_TTL_MS = 30_000;
|
||||
const NATIVE_PREFERENCE_GRACE_MS = 50;
|
||||
|
||||
export type SignalSseEvent = {
|
||||
event?: string;
|
||||
data?: string;
|
||||
};
|
||||
|
||||
export type SignalApiMode = "native" | "container" | "auto";
|
||||
|
||||
// Re-export the options type so consumers can import it from the adapter.
|
||||
export type { SignalRpcOptions } from "./client.js";
|
||||
|
||||
// Cache auto-detected modes per baseUrl to avoid repeated network probes.
|
||||
const detectedModeCache = new Map<
|
||||
string,
|
||||
{ mode: "native" | "container"; expiresAt: number; receiveAccount?: string }
|
||||
>();
|
||||
|
||||
function resolveConfiguredApiMode(configured?: SignalApiMode): SignalApiMode {
|
||||
if (configured === "native" || configured === "container") {
|
||||
return configured;
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function resolveAutoProbeTimeoutMs(timeoutMs: number | undefined): number {
|
||||
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function waitForNativePreferenceGrace(
|
||||
nativeResultPromise: Promise<{ ok: boolean }>,
|
||||
): Promise<{ ok: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve({ ok: false }), NATIVE_PREFERENCE_GRACE_MS);
|
||||
timer.unref?.();
|
||||
void nativeResultPromise.then((result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveAutoApiMode(
|
||||
baseUrl: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
options: { account?: string; requireContainerReceive?: boolean } = {},
|
||||
): Promise<"native" | "container"> {
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
const cached = detectedModeCache.get(baseUrl);
|
||||
if (cached) {
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
if (
|
||||
cached.mode !== "container" ||
|
||||
!options.requireContainerReceive ||
|
||||
(Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim())
|
||||
) {
|
||||
return cached.mode;
|
||||
}
|
||||
} else {
|
||||
detectedModeCache.delete(baseUrl);
|
||||
}
|
||||
}
|
||||
const detected = await detectSignalApiMode(baseUrl, timeoutMs, options);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(MODE_CACHE_TTL_MS, { nowMs: rawNow });
|
||||
if (expiresAt !== undefined) {
|
||||
detectedModeCache.set(baseUrl, {
|
||||
mode: detected,
|
||||
expiresAt,
|
||||
...(detected === "container" && options.requireContainerReceive && options.account
|
||||
? { receiveAccount: options.account }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return detected;
|
||||
}
|
||||
|
||||
async function resolveApiModeForOperation(params: {
|
||||
baseUrl: string;
|
||||
accountId?: string;
|
||||
account?: string;
|
||||
requireContainerReceive?: boolean;
|
||||
timeoutMs?: number;
|
||||
apiMode?: SignalApiMode;
|
||||
}): Promise<"native" | "container"> {
|
||||
const configured = resolveConfiguredApiMode(params.apiMode);
|
||||
|
||||
if (configured === "native" || configured === "container") {
|
||||
return configured;
|
||||
}
|
||||
|
||||
return resolveAutoApiMode(params.baseUrl, params.timeoutMs ?? DEFAULT_TIMEOUT_MS, {
|
||||
account: params.account,
|
||||
requireContainerReceive: params.requireContainerReceive,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which Signal API mode is available by probing endpoints.
|
||||
* Native wins when both APIs are healthy because it preserves the richer JSON-RPC contract.
|
||||
*/
|
||||
export async function detectSignalApiMode(
|
||||
baseUrl: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
options: { account?: string; requireContainerReceive?: boolean } = {},
|
||||
): Promise<"native" | "container"> {
|
||||
const containerAccount = options.requireContainerReceive ? options.account?.trim() : undefined;
|
||||
const nativeResultPromise = nativeCheck(baseUrl, timeoutMs).catch(() => ({ ok: false }));
|
||||
const containerResultPromise = containerAccount
|
||||
? containerCheck(baseUrl, timeoutMs, containerAccount).catch(() => ({ ok: false }))
|
||||
: options.requireContainerReceive
|
||||
? Promise.resolve({ ok: false })
|
||||
: containerCheck(baseUrl, timeoutMs).catch(() => ({ ok: false }));
|
||||
|
||||
const nativeHealthyPromise = nativeResultPromise.then((result) => {
|
||||
if (result.ok) {
|
||||
return "native" as const;
|
||||
}
|
||||
throw new Error("native not ok");
|
||||
});
|
||||
const containerHealthyPromise = containerResultPromise.then((result) => {
|
||||
if (result.ok) {
|
||||
return "container" as const;
|
||||
}
|
||||
throw new Error("container not ok");
|
||||
});
|
||||
|
||||
try {
|
||||
const firstHealthy = await Promise.any([nativeHealthyPromise, containerHealthyPromise]);
|
||||
if (firstHealthy === "native") {
|
||||
return "native";
|
||||
}
|
||||
const nativeResult = await waitForNativePreferenceGrace(nativeResultPromise);
|
||||
return nativeResult.ok ? "native" : "container";
|
||||
} catch {
|
||||
throw new Error(`Signal API not reachable at ${baseUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for native signalRpcRequest.
|
||||
* Routes to native JSON-RPC or container REST based on config.
|
||||
*/
|
||||
export async function signalRpcRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
opts: SignalRpcOptions & { accountId?: string; apiMode?: SignalApiMode },
|
||||
): Promise<T> {
|
||||
const mode = await resolveApiModeForOperation({
|
||||
baseUrl: opts.baseUrl,
|
||||
accountId: opts.accountId,
|
||||
account: typeof params?.account === "string" ? params.account : undefined,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
apiMode: opts.apiMode,
|
||||
});
|
||||
if (mode === "native") {
|
||||
return nativeRpcRequest<T>(method, params, opts);
|
||||
}
|
||||
return containerRpcRequest<T>(method, params, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for native signalCheck.
|
||||
*/
|
||||
export async function signalCheck(
|
||||
baseUrl: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
options: { apiMode?: SignalApiMode } = {},
|
||||
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
|
||||
const configured = resolveConfiguredApiMode(options.apiMode);
|
||||
const mode =
|
||||
configured === "auto"
|
||||
? await resolveAutoApiMode(baseUrl, timeoutMs).catch((error: unknown) => {
|
||||
return { ok: false, status: null, error: formatErrorMessage(error) } as const;
|
||||
})
|
||||
: configured;
|
||||
if (typeof mode !== "string") {
|
||||
return mode;
|
||||
}
|
||||
if (mode === "container") {
|
||||
return containerCheck(baseUrl, timeoutMs);
|
||||
}
|
||||
return nativeCheck(baseUrl, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for native streamSignalEvents.
|
||||
* Container mode uses WebSocket; native uses SSE.
|
||||
*/
|
||||
export async function streamSignalEvents(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
onEvent: (event: SignalSseEvent) => void;
|
||||
logger?: { log?: (msg: string) => void; error?: (msg: string) => void };
|
||||
apiMode?: SignalApiMode;
|
||||
}): Promise<void> {
|
||||
const mode = await resolveApiModeForOperation({
|
||||
baseUrl: params.baseUrl,
|
||||
accountId: params.accountId,
|
||||
account: params.account,
|
||||
requireContainerReceive: true,
|
||||
timeoutMs: resolveAutoProbeTimeoutMs(params.timeoutMs),
|
||||
apiMode: params.apiMode,
|
||||
});
|
||||
|
||||
if (mode === "container") {
|
||||
return streamContainerEvents({
|
||||
baseUrl: params.baseUrl,
|
||||
account: params.account,
|
||||
abortSignal: params.abortSignal,
|
||||
timeoutMs: params.timeoutMs,
|
||||
onEvent: (event) => params.onEvent({ event: "receive", data: JSON.stringify(event) }),
|
||||
logger: params.logger,
|
||||
});
|
||||
}
|
||||
|
||||
return nativeStreamEvents({
|
||||
baseUrl: params.baseUrl,
|
||||
account: params.account,
|
||||
abortSignal: params.abortSignal,
|
||||
timeoutMs: params.timeoutMs,
|
||||
onEvent: (event) => params.onEvent(event),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch attachment, routing to native or container implementation.
|
||||
*/
|
||||
export async function fetchAttachment(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
attachmentId: string;
|
||||
sender?: string;
|
||||
groupId?: string;
|
||||
timeoutMs?: number;
|
||||
maxResponseBytes?: number;
|
||||
apiMode?: SignalApiMode;
|
||||
}): Promise<Buffer | null> {
|
||||
const mode = await resolveApiModeForOperation({
|
||||
baseUrl: params.baseUrl,
|
||||
accountId: params.accountId,
|
||||
account: params.account,
|
||||
timeoutMs: params.timeoutMs,
|
||||
apiMode: params.apiMode,
|
||||
});
|
||||
if (mode === "container") {
|
||||
return containerFetchAttachment(params.attachmentId, {
|
||||
baseUrl: params.baseUrl,
|
||||
timeoutMs: params.timeoutMs,
|
||||
maxResponseBytes: params.maxResponseBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
id: params.attachmentId,
|
||||
};
|
||||
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 nativeRpcRequest<{ data?: string }>("getAttachment", rpcParams, {
|
||||
baseUrl: params.baseUrl,
|
||||
timeoutMs: params.timeoutMs,
|
||||
maxResponseBytes: params.maxResponseBytes,
|
||||
});
|
||||
if (!result?.data) {
|
||||
return null;
|
||||
}
|
||||
return Buffer.from(result.data, "base64");
|
||||
}
|
||||
1075
extensions/signal/src/client-container.test.ts
Normal file
1075
extensions/signal/src/client-container.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
768
extensions/signal/src/client-container.ts
Normal file
768
extensions/signal/src/client-container.ts
Normal file
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* Signal client for bbernhard/signal-cli-rest-api container.
|
||||
* Uses WebSocket for receiving messages and REST API for sending.
|
||||
*
|
||||
* This is a separate implementation from client.ts (native signal-cli)
|
||||
* to keep the two modes cleanly isolated.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import nodePath from "node:path";
|
||||
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import { detectMime, parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
parseStrictNonNegativeInteger,
|
||||
resolveTimerTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
readProviderTextResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import WebSocket from "ws";
|
||||
|
||||
export type ContainerRpcOptions = {
|
||||
baseUrl: string;
|
||||
timeoutMs?: number;
|
||||
maxResponseBytes?: number;
|
||||
};
|
||||
|
||||
export type ContainerWebSocketMessage = {
|
||||
envelope?: {
|
||||
syncMessage?: unknown;
|
||||
dataMessage?: {
|
||||
message?: string;
|
||||
groupInfo?: { groupId?: string; groupName?: string };
|
||||
attachments?: Array<{
|
||||
id?: string;
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
}>;
|
||||
quote?: { text?: string };
|
||||
reaction?: unknown;
|
||||
};
|
||||
editMessage?: { dataMessage?: unknown };
|
||||
reactionMessage?: unknown;
|
||||
sourceNumber?: string;
|
||||
sourceUuid?: string;
|
||||
sourceName?: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
exception?: { message?: string };
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_ATTACHMENT_RESPONSE_MAX_BYTES = 1_048_576;
|
||||
const CONTAINER_TEXT_STYLE_MARKERS: Record<string, string> = {
|
||||
BOLD: "**",
|
||||
ITALIC: "*",
|
||||
STRIKETHROUGH: "~",
|
||||
MONOSPACE: "`",
|
||||
SPOILER: "||",
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Signal base URL is required");
|
||||
}
|
||||
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||
const parsed = new URL(withProtocol);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Signal base URL unsupported protocol: ${parsed.protocol}`);
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error("Signal base URL must not include credentials");
|
||||
}
|
||||
const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
|
||||
return `${parsed.protocol}//${parsed.host}${pathname}`;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number) {
|
||||
const fetchImpl = resolveFetch();
|
||||
if (!fetchImpl) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
const safeTimeoutMs = resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), safeTimeoutMs);
|
||||
try {
|
||||
return await fetchImpl(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMaxResponseBytes(value: number | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return DEFAULT_ATTACHMENT_RESPONSE_MAX_BYTES;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function readContentLength(res: Response): number | undefined {
|
||||
return parseMediaContentLength(res.headers?.get("content-length") ?? null) ?? undefined;
|
||||
}
|
||||
|
||||
async function readCappedResponseBuffer(res: Response, maxResponseBytes: number): Promise<Buffer> {
|
||||
const contentLength = readContentLength(res);
|
||||
if (contentLength !== undefined && contentLength > maxResponseBytes) {
|
||||
throw new Error("Signal REST attachment exceeded size limit");
|
||||
}
|
||||
return await readResponseWithLimit(res, maxResponseBytes, {
|
||||
onOverflow: () => new Error("Signal REST attachment exceeded size limit"),
|
||||
});
|
||||
}
|
||||
|
||||
async function releaseUnreadResponseBody(res: Response | undefined): Promise<void> {
|
||||
if (res?.bodyUsed !== true) {
|
||||
await res?.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bbernhard container REST API is available.
|
||||
*/
|
||||
export async function containerCheck(
|
||||
baseUrl: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
account?: string,
|
||||
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
let res: Response | undefined;
|
||||
try {
|
||||
res = await fetchWithTimeout(`${normalized}/v1/about`, { method: "GET" }, timeoutMs);
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const receiveAccount = account?.trim();
|
||||
if (receiveAccount) {
|
||||
return await containerReceiveCheck(normalized, receiveAccount, timeoutMs);
|
||||
}
|
||||
return { ok: true, status: res.status, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
await releaseUnreadResponseBody(res);
|
||||
}
|
||||
}
|
||||
|
||||
function containerReceiveCheck(
|
||||
normalizedBaseUrl: string,
|
||||
account: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
|
||||
const wsUrl = `${normalizedBaseUrl.replace(/^http/, "ws")}/v1/receive/${encodeURIComponent(account)}`;
|
||||
return new Promise((resolve) => {
|
||||
const safeTimeoutMs = resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
let settled = false;
|
||||
let ws: WebSocket | undefined;
|
||||
const timer = setTimeout(() => {
|
||||
settle({ ok: false, status: null, error: "Signal container receive WebSocket timed out" });
|
||||
ws?.terminate();
|
||||
}, safeTimeoutMs);
|
||||
timer.unref?.();
|
||||
const settle = (result: { ok: boolean; status?: number | null; error?: string | null }) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch (err) {
|
||||
settle({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
ws.once("open", () => {
|
||||
settle({ ok: true, status: 101, error: null });
|
||||
ws?.close();
|
||||
});
|
||||
ws.once("unexpected-response", (_request, response) => {
|
||||
settle({
|
||||
ok: false,
|
||||
status: response.statusCode ?? null,
|
||||
error: `Signal container receive endpoint did not upgrade to WebSocket (HTTP ${
|
||||
response.statusCode ?? "unknown"
|
||||
})`,
|
||||
});
|
||||
ws?.terminate();
|
||||
});
|
||||
ws.once("error", (err) => {
|
||||
settle({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
ws.once("close", (code, reason) => {
|
||||
const reasonText = reason.length > 0 ? `: ${reason.toString("utf8")}` : "";
|
||||
settle({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: `Signal container receive WebSocket closed before open (${code}${reasonText})`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a REST API request to bbernhard container.
|
||||
*/
|
||||
export async function containerRestRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
opts: ContainerRpcOptions,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const baseUrl = normalizeBaseUrl(opts.baseUrl);
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
};
|
||||
|
||||
if (body) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const res = await fetchWithTimeout(url, init, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// Bound the error body: signal-cli-rest-api is an untrusted external container,
|
||||
// and a hostile/buggy response must not let an error path buffer an unbounded body.
|
||||
const errorText = await readResponseTextLimited(res).catch(() => "");
|
||||
throw new Error(`Signal REST ${res.status}: ${errorText || res.statusText}`);
|
||||
}
|
||||
|
||||
// Bound the success body under the shared 16 MiB provider cap before JSON.parse so a
|
||||
// malicious/runaway container response cannot OOM the runtime (send/typing/version all
|
||||
// funnel through here). Reuse the same bounded reader family as the attachment path.
|
||||
const text = await readProviderTextResponse(res, "Signal REST");
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new Error("Signal REST returned malformed JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch attachment binary from bbernhard container.
|
||||
*/
|
||||
export async function containerFetchAttachment(
|
||||
attachmentId: string,
|
||||
opts: ContainerRpcOptions,
|
||||
): Promise<Buffer | null> {
|
||||
const baseUrl = normalizeBaseUrl(opts.baseUrl);
|
||||
const url = `${baseUrl}/v1/attachments/${encodeURIComponent(attachmentId)}`;
|
||||
let res: Response | undefined;
|
||||
|
||||
try {
|
||||
res = await fetchWithTimeout(url, { method: "GET" }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await readCappedResponseBuffer(res, normalizeMaxResponseBytes(opts.maxResponseBytes));
|
||||
} finally {
|
||||
await releaseUnreadResponseBody(res);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream messages using WebSocket from bbernhard container.
|
||||
* The Promise resolves when the connection closes (for any reason).
|
||||
* The caller (runSignalLoopAdapter) is responsible for reconnection.
|
||||
*/
|
||||
export async function streamContainerEvents(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
onEvent: (event: ContainerWebSocketMessage) => void;
|
||||
logger?: { log?: (msg: string) => void; error?: (msg: string) => void };
|
||||
}): Promise<void> {
|
||||
const normalized = normalizeBaseUrl(params.baseUrl);
|
||||
const wsUrl = `${normalized.replace(/^http/, "ws")}/v1/receive/${encodeURIComponent(params.account ?? "")}`;
|
||||
const redactedWsUrl = `${normalized.replace(/^http/, "ws")}/v1/receive/<redacted>`;
|
||||
const log = params.logger?.log ?? (() => {});
|
||||
const logError = params.logger?.error ?? (() => {});
|
||||
|
||||
log(`[signal-ws] connecting to ${redactedWsUrl}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let ws: WebSocket;
|
||||
let resolved = false;
|
||||
let abortHandler: (() => void) | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
if (abortHandler) {
|
||||
params.abortSignal?.removeEventListener("abort", abortHandler);
|
||||
abortHandler = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch (err) {
|
||||
logError(
|
||||
`[signal-ws] failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
reject(toLintErrorObject(err, "Non-Error rejection"));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on("open", () => {
|
||||
log("[signal-ws] connected");
|
||||
});
|
||||
|
||||
ws.on("message", (data: Buffer) => {
|
||||
try {
|
||||
const text = data.toString();
|
||||
const envelope = JSON.parse(text) as ContainerWebSocketMessage;
|
||||
if (envelope) {
|
||||
params.onEvent(envelope);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`[signal-ws] parse error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
logError(`[signal-ws] error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// Don't resolve here - the close event will fire next
|
||||
});
|
||||
|
||||
ws.on("close", (code, reason) => {
|
||||
const reasonStr = reason?.toString() || "no reason";
|
||||
log(`[signal-ws] closed (code=${code}, reason=${reasonStr})`);
|
||||
cleanup();
|
||||
resolve(); // Let the outer loop handle reconnection
|
||||
});
|
||||
|
||||
ws.on("ping", () => {
|
||||
log("[signal-ws] ping received");
|
||||
});
|
||||
|
||||
ws.on("pong", () => {
|
||||
log("[signal-ws] pong received");
|
||||
});
|
||||
|
||||
if (params.abortSignal) {
|
||||
abortHandler = () => {
|
||||
log("[signal-ws] aborted, closing connection");
|
||||
cleanup();
|
||||
ws.close();
|
||||
resolve();
|
||||
};
|
||||
params.abortSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert local file paths to base64 data URIs for the container REST API.
|
||||
* The bbernhard container /v2/send only accepts `base64_attachments` (not file paths).
|
||||
*/
|
||||
async function filesToBase64DataUris(filePaths: string[]): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
const mime = (await detectMime({ buffer, filePath })) ?? "application/octet-stream";
|
||||
const filename = nodePath.basename(filePath);
|
||||
const b64 = buffer.toString("base64");
|
||||
results.push(`data:${mime};filename=${filename};base64,${b64}`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function escapeContainerStyledText(text: string): string {
|
||||
return text.replace(/[*~`|]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
function renderContainerStyledText(
|
||||
text: string,
|
||||
styles: Array<{ start: number; length: number; style: string }>,
|
||||
): string {
|
||||
const spans = styles
|
||||
.map((style) => {
|
||||
const marker = CONTAINER_TEXT_STYLE_MARKERS[style.style];
|
||||
if (!marker) {
|
||||
return null;
|
||||
}
|
||||
const start = Math.max(0, Math.min(style.start, text.length));
|
||||
const end = Math.max(start, Math.min(style.start + style.length, text.length));
|
||||
if (end <= start) {
|
||||
return null;
|
||||
}
|
||||
return { start, end, marker };
|
||||
})
|
||||
.filter((span): span is { start: number; end: number; marker: string } => span !== null);
|
||||
|
||||
if (spans.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const positions = [
|
||||
...new Set([0, text.length, ...spans.flatMap((span) => [span.start, span.end])]),
|
||||
].toSorted((a, b) => a - b);
|
||||
let rendered = "";
|
||||
for (let i = 0; i < positions.length; i += 1) {
|
||||
const pos = positions[i];
|
||||
for (const span of spans
|
||||
.filter((candidate) => candidate.end === pos)
|
||||
.toSorted((a, b) => b.start - a.start)) {
|
||||
rendered += span.marker;
|
||||
}
|
||||
for (const span of spans
|
||||
.filter((candidate) => candidate.start === pos)
|
||||
.toSorted((a, b) => b.end - a.end)) {
|
||||
rendered += span.marker;
|
||||
}
|
||||
const next = positions[i + 1];
|
||||
if (next !== undefined && next > pos) {
|
||||
rendered += escapeContainerStyledText(text.slice(pos, next));
|
||||
}
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function parseContainerSendTimestamp(raw: unknown): number | undefined {
|
||||
if (raw == null) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = parseStrictNonNegativeInteger(raw);
|
||||
if (timestamp === undefined) {
|
||||
throw new Error("Signal REST send returned invalid timestamp");
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message via bbernhard container REST API.
|
||||
*/
|
||||
export async function containerSendMessage(params: {
|
||||
baseUrl: string;
|
||||
account: string;
|
||||
recipients: string[];
|
||||
message: string;
|
||||
textStyles?: Array<{ start: number; length: number; style: string }>;
|
||||
attachments?: string[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ timestamp?: number }> {
|
||||
const payload: Record<string, unknown> = {
|
||||
message: params.message,
|
||||
number: params.account,
|
||||
recipients: params.recipients,
|
||||
};
|
||||
|
||||
if (params.textStyles && params.textStyles.length > 0) {
|
||||
payload.message = renderContainerStyledText(params.message, params.textStyles);
|
||||
payload["text_mode"] = "styled";
|
||||
}
|
||||
|
||||
if (params.attachments && params.attachments.length > 0) {
|
||||
// Container API only accepts base64-encoded attachments, not file paths.
|
||||
payload.base64_attachments = await filesToBase64DataUris(params.attachments);
|
||||
}
|
||||
|
||||
const result = await containerRestRequest<{ timestamp?: unknown }>(
|
||||
"/v2/send",
|
||||
{ baseUrl: params.baseUrl, timeoutMs: params.timeoutMs },
|
||||
"POST",
|
||||
payload,
|
||||
);
|
||||
|
||||
const timestamp = parseContainerSendTimestamp(result?.timestamp);
|
||||
return timestamp === undefined ? {} : { timestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send typing indicator via bbernhard container REST API.
|
||||
*/
|
||||
export async function containerSendTyping(params: {
|
||||
baseUrl: string;
|
||||
account: string;
|
||||
recipient: string;
|
||||
stop?: boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
const method = params.stop ? "DELETE" : "PUT";
|
||||
await containerRestRequest(
|
||||
`/v1/typing-indicator/${encodeURIComponent(params.account)}`,
|
||||
{ baseUrl: params.baseUrl, timeoutMs: params.timeoutMs },
|
||||
method,
|
||||
{ recipient: params.recipient },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send read receipt via bbernhard container REST API.
|
||||
*/
|
||||
export async function containerSendReceipt(params: {
|
||||
baseUrl: string;
|
||||
account: string;
|
||||
recipient: string;
|
||||
timestamp: number;
|
||||
type?: "read" | "viewed";
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
await containerRestRequest(
|
||||
`/v1/receipts/${encodeURIComponent(params.account)}`,
|
||||
{ baseUrl: params.baseUrl, timeoutMs: params.timeoutMs },
|
||||
"POST",
|
||||
{
|
||||
recipient: params.recipient,
|
||||
timestamp: params.timestamp,
|
||||
receipt_type: params.type ?? "read",
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction to a message via bbernhard container REST API.
|
||||
*/
|
||||
export async function containerSendReaction(params: {
|
||||
baseUrl: string;
|
||||
account: string;
|
||||
recipient: string;
|
||||
emoji: string;
|
||||
targetAuthor: string;
|
||||
targetTimestamp: number;
|
||||
groupId?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ timestamp?: number }> {
|
||||
const payload: Record<string, unknown> = {
|
||||
recipient: params.recipient,
|
||||
reaction: params.emoji,
|
||||
target_author: params.targetAuthor,
|
||||
timestamp: params.targetTimestamp,
|
||||
};
|
||||
|
||||
if (params.groupId) {
|
||||
payload.group_id = params.groupId;
|
||||
}
|
||||
|
||||
const result = await containerRestRequest<{ timestamp?: number }>(
|
||||
`/v1/reactions/${encodeURIComponent(params.account)}`,
|
||||
{ baseUrl: params.baseUrl, timeoutMs: params.timeoutMs },
|
||||
"POST",
|
||||
payload,
|
||||
);
|
||||
|
||||
return result ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reaction from a message via bbernhard container REST API.
|
||||
*/
|
||||
export async function containerRemoveReaction(params: {
|
||||
baseUrl: string;
|
||||
account: string;
|
||||
recipient: string;
|
||||
emoji: string;
|
||||
targetAuthor: string;
|
||||
targetTimestamp: number;
|
||||
groupId?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ timestamp?: number }> {
|
||||
const payload: Record<string, unknown> = {
|
||||
recipient: params.recipient,
|
||||
reaction: params.emoji,
|
||||
target_author: params.targetAuthor,
|
||||
timestamp: params.targetTimestamp,
|
||||
};
|
||||
|
||||
if (params.groupId) {
|
||||
payload.group_id = params.groupId;
|
||||
}
|
||||
|
||||
const result = await containerRestRequest<{ timestamp?: number }>(
|
||||
`/v1/reactions/${encodeURIComponent(params.account)}`,
|
||||
{ baseUrl: params.baseUrl, timeoutMs: params.timeoutMs },
|
||||
"DELETE",
|
||||
payload,
|
||||
);
|
||||
|
||||
return result ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the "uuid:" prefix that native signal-cli accepts but the container API rejects.
|
||||
*/
|
||||
function stripUuidPrefix(id: string): string {
|
||||
return id.startsWith("uuid:") ? id.slice(5) : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a group internal_id to the container-expected format.
|
||||
* The bbernhard container expects groups as "group.{base64(internal_id)}".
|
||||
*/
|
||||
function formatGroupIdForContainer(groupId: string): string {
|
||||
if (groupId.startsWith("group.")) {
|
||||
return groupId;
|
||||
}
|
||||
return `group.${Buffer.from(groupId).toString("base64")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for native signalRpcRequest that translates
|
||||
* JSON-RPC method + params into the equivalent container REST API calls.
|
||||
* This keeps all container protocol details (uuid: stripping, group ID
|
||||
* formatting, base64 attachments, text-style conversion) isolated here.
|
||||
*/
|
||||
export async function containerRpcRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
opts: ContainerRpcOptions,
|
||||
): Promise<T> {
|
||||
const p = params ?? {};
|
||||
switch (method) {
|
||||
case "send": {
|
||||
const recipients = ((p.recipient as string[] | undefined) ?? []).map(stripUuidPrefix);
|
||||
const usernames = ((p.username as string[] | undefined) ?? []).map(stripUuidPrefix);
|
||||
const groupId = p.groupId as string | undefined;
|
||||
const formattedGroupId = groupId ? formatGroupIdForContainer(groupId) : undefined;
|
||||
const finalRecipients =
|
||||
recipients.length > 0
|
||||
? recipients
|
||||
: usernames.length > 0
|
||||
? usernames
|
||||
: formattedGroupId
|
||||
? [formattedGroupId]
|
||||
: [];
|
||||
|
||||
const textStylesRaw = p["text-style"] as string[] | undefined;
|
||||
const textStyles = textStylesRaw?.map((s) => {
|
||||
const [start, length, style] = s.split(":");
|
||||
return { start: Number(start), length: Number(length), style };
|
||||
});
|
||||
|
||||
const result = await containerSendMessage({
|
||||
baseUrl: opts.baseUrl,
|
||||
account: (p.account as string) ?? "",
|
||||
recipients: finalRecipients,
|
||||
message: (p.message as string) ?? "",
|
||||
textStyles,
|
||||
attachments: p.attachments as string[] | undefined,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return result as T;
|
||||
}
|
||||
|
||||
case "sendTyping": {
|
||||
const recipient = stripUuidPrefix(
|
||||
(p.recipient as string[] | undefined)?.[0] ??
|
||||
((p.groupId as string | undefined) ? formatGroupIdForContainer(p.groupId as string) : ""),
|
||||
);
|
||||
await containerSendTyping({
|
||||
baseUrl: opts.baseUrl,
|
||||
account: (p.account as string) ?? "",
|
||||
recipient,
|
||||
stop: p.stop as boolean | undefined,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
case "sendReceipt": {
|
||||
const recipient = stripUuidPrefix((p.recipient as string[] | undefined)?.[0] ?? "");
|
||||
await containerSendReceipt({
|
||||
baseUrl: opts.baseUrl,
|
||||
account: (p.account as string) ?? "",
|
||||
recipient,
|
||||
timestamp: p.targetTimestamp as number,
|
||||
type: p.type as "read" | "viewed" | undefined,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
case "sendReaction": {
|
||||
const recipient = stripUuidPrefix((p.recipients as string[] | undefined)?.[0] ?? "");
|
||||
const groupId = (p.groupIds as string[] | undefined)?.[0] ?? undefined;
|
||||
const formattedGroupId = groupId ? formatGroupIdForContainer(groupId) : undefined;
|
||||
// Container API uses `recipient` for both DMs and groups.
|
||||
// For groups, pass the formatted group ID as recipient.
|
||||
const effectiveRecipient = formattedGroupId || recipient || "";
|
||||
const reactionParams = {
|
||||
baseUrl: opts.baseUrl,
|
||||
account: (p.account as string) ?? "",
|
||||
recipient: effectiveRecipient,
|
||||
emoji: (p.emoji as string) ?? "",
|
||||
targetAuthor: stripUuidPrefix((p.targetAuthor as string) ?? recipient),
|
||||
targetTimestamp: p.targetTimestamp as number,
|
||||
groupId: formattedGroupId,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
};
|
||||
const fn = p.remove ? containerRemoveReaction : containerSendReaction;
|
||||
return (await fn(reactionParams)) as T;
|
||||
}
|
||||
|
||||
case "getAttachment": {
|
||||
const attachmentId = p.id as string;
|
||||
const buffer = await containerFetchAttachment(attachmentId, {
|
||||
baseUrl: opts.baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
maxResponseBytes: opts.maxResponseBytes,
|
||||
});
|
||||
// Convert to native format: { data: base64String }
|
||||
if (!buffer) {
|
||||
return { data: undefined } as T;
|
||||
}
|
||||
return { data: buffer.toString("base64") } as T;
|
||||
}
|
||||
|
||||
case "version": {
|
||||
const result = await containerRestRequest<{ versions?: string[]; build?: number }>(
|
||||
"/v1/about",
|
||||
{ baseUrl: opts.baseUrl, timeoutMs: opts.timeoutMs },
|
||||
);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported container RPC method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
374
extensions/signal/src/client.test.ts
Normal file
374
extensions/signal/src/client.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
// Signal tests cover client plugin behavior.
|
||||
import { Buffer } from "node:buffer";
|
||||
import { once } from "node:events";
|
||||
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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",
|
||||
};
|
||||
});
|
||||
|
||||
let signalCheck: typeof import("./client.js").signalCheck;
|
||||
let signalRpcRequest: typeof import("./client.js").signalRpcRequest;
|
||||
let streamSignalEvents: typeof import("./client.js").streamSignalEvents;
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
async function readRequestBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req as AsyncIterable<Buffer | string>) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
async function withSignalServer(
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
|
||||
): Promise<string> {
|
||||
const server = http.createServer((req, res) => {
|
||||
void Promise.resolve(handler(req, res)).catch((error: unknown) => {
|
||||
res.writeHead(500, { "Content-Type": "text/plain" });
|
||||
res.end(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("missing test server address");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ signalCheck, signalRpcRequest, streamSignalEvents } = await import("./client.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe("signalRpcRequest", () => {
|
||||
it("returns parsed RPC result", async () => {
|
||||
const baseUrl = await withSignalServer(async (req, res) => {
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.url).toBe("/api/v1/rpc");
|
||||
expect(req.headers["content-type"]).toBe("application/json");
|
||||
expect(JSON.parse(await readRequestBody(req))).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
method: "version",
|
||||
id: "test-id",
|
||||
});
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" }));
|
||||
});
|
||||
|
||||
const result = await signalRpcRequest<{ version: string }>("version", undefined, {
|
||||
baseUrl,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ version: "0.13.22" });
|
||||
});
|
||||
|
||||
it("throws a wrapped error when RPC response JSON is malformed", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(502, { "Content-Type": "text/plain" });
|
||||
res.end("not-json");
|
||||
});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await signalRpcRequest("version", undefined, {
|
||||
baseUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
if (!(thrown instanceof Error)) {
|
||||
throw new Error("expected malformed JSON request to throw an Error");
|
||||
}
|
||||
expect(thrown.message).toBe("Signal RPC returned malformed JSON (status 502)");
|
||||
expect(thrown.cause).toBeInstanceOf(SyntaxError);
|
||||
});
|
||||
|
||||
it("throws when RPC response envelope has neither result nor error", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ jsonrpc: "2.0", id: "test-id" }));
|
||||
});
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl,
|
||||
}),
|
||||
).rejects.toThrow("Signal RPC returned invalid response envelope (status 200)");
|
||||
});
|
||||
|
||||
it("rejects credentialed base URLs", async () => {
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl: "http://user:pass@127.0.0.1:8080",
|
||||
}),
|
||||
).rejects.toThrow("Signal base URL must not include credentials");
|
||||
});
|
||||
|
||||
it("rejects oversized RPC responses", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end("x".repeat(1_048_577));
|
||||
});
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl,
|
||||
}),
|
||||
).rejects.toThrow("Signal HTTP response exceeded size limit");
|
||||
});
|
||||
|
||||
it("accepts RPC responses larger than the default cap when maxResponseBytes is raised", async () => {
|
||||
const payload = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
result: { data: "y".repeat(1_200_000) },
|
||||
id: "test-id",
|
||||
});
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(payload);
|
||||
});
|
||||
|
||||
const result = await signalRpcRequest<{ data: string }>("getAttachment", undefined, {
|
||||
baseUrl,
|
||||
maxResponseBytes: 4_000_000,
|
||||
});
|
||||
|
||||
expect(result.data.length).toBe(1_200_000);
|
||||
});
|
||||
|
||||
it("rejects RPC responses that exceed a custom maxResponseBytes cap", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end("x".repeat(8_193));
|
||||
});
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("getAttachment", undefined, {
|
||||
baseUrl,
|
||||
maxResponseBytes: 8_192,
|
||||
}),
|
||||
).rejects.toThrow("Signal HTTP response exceeded size limit");
|
||||
});
|
||||
|
||||
it("falls back to the default cap when maxResponseBytes is zero or non-finite", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end("x".repeat(1_048_577));
|
||||
});
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl,
|
||||
maxResponseBytes: 0,
|
||||
}),
|
||||
).rejects.toThrow("Signal HTTP response exceeded size limit");
|
||||
|
||||
const baseUrl2 = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end("x".repeat(1_048_577));
|
||||
});
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl: baseUrl2,
|
||||
maxResponseBytes: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
).rejects.toThrow("Signal HTTP response exceeded size limit");
|
||||
});
|
||||
|
||||
it("uses an absolute deadline for slow-drip RPC responses", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
const interval = setInterval(() => {
|
||||
res.write(" ");
|
||||
}, 5);
|
||||
res.on("close", () => clearInterval(interval));
|
||||
});
|
||||
|
||||
await expect(
|
||||
signalRpcRequest("version", undefined, {
|
||||
baseUrl,
|
||||
timeoutMs: 25,
|
||||
}),
|
||||
).rejects.toThrow("Signal HTTP exceeded deadline after 25ms");
|
||||
});
|
||||
|
||||
it("caps oversized RPC request timeouts before scheduling", async () => {
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
|
||||
vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined);
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" }));
|
||||
});
|
||||
|
||||
await signalRpcRequest("version", undefined, {
|
||||
baseUrl,
|
||||
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1_000_000,
|
||||
});
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalCheck", () => {
|
||||
it("returns ok for a healthy signal-cli check", async () => {
|
||||
const baseUrl = await withSignalServer((req, res) => {
|
||||
expect(req.method).toBe("GET");
|
||||
expect(req.url).toBe("/api/v1/check");
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(signalCheck(baseUrl)).resolves.toEqual({ ok: true, status: 204, error: null });
|
||||
});
|
||||
|
||||
it("returns an HTTP status failure for unhealthy checks", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(503);
|
||||
res.end("down");
|
||||
});
|
||||
|
||||
await expect(signalCheck(baseUrl)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 503,
|
||||
error: "HTTP 503",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamSignalEvents", () => {
|
||||
it("streams events through node http instead of fetch", async () => {
|
||||
const events: Array<import("./client.js").SignalSseEvent> = [];
|
||||
const baseUrl = await withSignalServer((req, res) => {
|
||||
expect(req.url).toBe("/api/v1/events?account=%2B15555550123");
|
||||
expect(req.headers.accept).toBe("text/event-stream");
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
||||
res.end('id: 42\nevent: message\ndata: {"group":true}\n\n');
|
||||
});
|
||||
|
||||
await streamSignalEvents({
|
||||
baseUrl,
|
||||
account: "+15555550123",
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ id: "42", event: "message", data: '{"group":true}' }]);
|
||||
});
|
||||
|
||||
it("reports HTTP status failures from the event stream", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(503, "Unavailable");
|
||||
res.end("down");
|
||||
});
|
||||
|
||||
await expect(
|
||||
streamSignalEvents({
|
||||
baseUrl,
|
||||
onEvent: () => {},
|
||||
}),
|
||||
).rejects.toThrow("Signal SSE failed (503 Unavailable)");
|
||||
});
|
||||
|
||||
it("rejects event streams that do not send headers before the deadline", async () => {
|
||||
const baseUrl = await withSignalServer(() => {
|
||||
// Leave the request open without response headers.
|
||||
});
|
||||
|
||||
await expect(
|
||||
streamSignalEvents({
|
||||
baseUrl,
|
||||
timeoutMs: 25,
|
||||
onEvent: () => {},
|
||||
}),
|
||||
).rejects.toThrow("Signal SSE connection timed out after 25ms");
|
||||
});
|
||||
|
||||
it("allows idle event streams to wait for abort when the deadline is disabled", async () => {
|
||||
const baseUrl = await withSignalServer(() => {
|
||||
// Leave the request open without response headers, matching signal-cli 0.14.3 before
|
||||
// its first keepalive flush.
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const abortTimer = setTimeout(() => abortController.abort(), 25);
|
||||
abortTimer.unref?.();
|
||||
|
||||
try {
|
||||
await streamSignalEvents({
|
||||
baseUrl,
|
||||
timeoutMs: 0,
|
||||
abortSignal: abortController.signal,
|
||||
onEvent: () => {},
|
||||
});
|
||||
throw new Error("expected Signal SSE stream to abort");
|
||||
} catch (error) {
|
||||
expect((error as Error).name).toBe("AbortError");
|
||||
expect((error as Error).message).toBe("Signal SSE aborted");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized SSE line buffers by byte size", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
||||
res.end(`data: ${"🙂".repeat(262_145)}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
streamSignalEvents({
|
||||
baseUrl,
|
||||
onEvent: () => {},
|
||||
}),
|
||||
).rejects.toThrow("Signal SSE buffer exceeded size limit");
|
||||
});
|
||||
|
||||
it("rejects oversized SSE events split across smaller data lines", async () => {
|
||||
const baseUrl = await withSignalServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
||||
const line = `data: ${"x".repeat(4096)}\n`;
|
||||
for (let index = 0; index < 260; index += 1) {
|
||||
res.write(line);
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
await expect(
|
||||
streamSignalEvents({
|
||||
baseUrl,
|
||||
onEvent: () => {},
|
||||
}),
|
||||
).rejects.toThrow("Signal SSE event data exceeded size limit");
|
||||
});
|
||||
});
|
||||
449
extensions/signal/src/client.ts
Normal file
449
extensions/signal/src/client.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
// Signal plugin module implements client behavior.
|
||||
import { Buffer } from "node:buffer";
|
||||
import http, { type ClientRequest, type IncomingMessage } from "node:http";
|
||||
import https from "node:https";
|
||||
import { generateSecureUuid } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
export type SignalRpcOptions = {
|
||||
baseUrl: string;
|
||||
timeoutMs?: number;
|
||||
maxResponseBytes?: 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;
|
||||
const DEFAULT_SIGNAL_HTTP_RESPONSE_MAX_BYTES = 1_048_576;
|
||||
const MAX_SIGNAL_SSE_BUFFER_BYTES = 1_048_576;
|
||||
const MAX_SIGNAL_SSE_EVENT_DATA_BYTES = 1_048_576;
|
||||
|
||||
type SignalHttpResponse = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function createSignalSseAbortError(): Error {
|
||||
const error = new Error("Signal SSE aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
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 parseSignalBaseUrl(url: string): URL {
|
||||
const parsed = new URL(normalizeBaseUrl(url));
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error("Signal base URL must not include credentials");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveSignalEndpointUrl(baseUrl: string, pathname: string): URL {
|
||||
return new URL(pathname, parseSignalBaseUrl(baseUrl));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function assertSignalHttpProtocol(url: URL, label: string): void {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Signal ${label} unsupported protocol: ${url.protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSignalHttpResponseMaxBytes(value: number | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return DEFAULT_SIGNAL_HTTP_RESPONSE_MAX_BYTES;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function normalizeSignalSseTimeoutMs(timeoutMs: number): number | null {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
return resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function requestSignalHttpText(
|
||||
url: URL,
|
||||
options: {
|
||||
method: "GET" | "POST";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs: number;
|
||||
maxResponseBytes?: number;
|
||||
},
|
||||
): Promise<SignalHttpResponse> {
|
||||
assertSignalHttpProtocol(url, "HTTP");
|
||||
const timeoutMs = resolveTimerTimeoutMs(options.timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
const client = url.protocol === "https:" ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const deadline = setTimeout(() => {
|
||||
request?.destroy(new Error(`Signal HTTP exceeded deadline after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
deadline.unref?.();
|
||||
const cleanup = () => {
|
||||
clearTimeout(deadline);
|
||||
request?.setTimeout(0);
|
||||
};
|
||||
const rejectOnce = (error: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(toLintErrorObject(error, "Non-Error rejection"));
|
||||
};
|
||||
const resolveOnce = (response: SignalHttpResponse) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(response);
|
||||
};
|
||||
const maxResponseBytes = normalizeSignalHttpResponseMaxBytes(options.maxResponseBytes);
|
||||
const request: ClientRequest | undefined = client.request(
|
||||
url,
|
||||
{
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
res.on("data", (chunk: Buffer | string) => {
|
||||
const next = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
||||
totalBytes += next.byteLength;
|
||||
if (totalBytes > maxResponseBytes) {
|
||||
const error = new Error("Signal HTTP response exceeded size limit");
|
||||
request?.destroy(error);
|
||||
res.destroy(error);
|
||||
rejectOnce(error);
|
||||
return;
|
||||
}
|
||||
chunks.push(next);
|
||||
});
|
||||
res.on("error", rejectOnce);
|
||||
res.on("end", () => {
|
||||
resolveOnce({
|
||||
status: res.statusCode ?? 0,
|
||||
statusText: res.statusMessage || "error",
|
||||
text: Buffer.concat(chunks).toString("utf8"),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
request.setTimeout(timeoutMs, () => {
|
||||
request?.destroy(new Error(`Signal HTTP timed out after ${timeoutMs}ms`));
|
||||
});
|
||||
request.on("error", rejectOnce);
|
||||
if (options.body !== undefined) {
|
||||
request.write(options.body);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function signalRpcRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
opts: SignalRpcOptions,
|
||||
): Promise<T> {
|
||||
const id = generateSecureUuid();
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params,
|
||||
id,
|
||||
});
|
||||
const res = await requestSignalHttpText(resolveSignalEndpointUrl(opts.baseUrl, "/api/v1/rpc"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": String(Buffer.byteLength(body)),
|
||||
},
|
||||
body,
|
||||
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxResponseBytes: opts.maxResponseBytes,
|
||||
});
|
||||
if (res.status === 201) {
|
||||
return undefined as T;
|
||||
}
|
||||
if (!res.text) {
|
||||
throw new Error(`Signal RPC empty response (status ${res.status})`);
|
||||
}
|
||||
const parsed = parseSignalRpcResponse<T>(res.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 }> {
|
||||
try {
|
||||
const res = await requestSignalHttpText(resolveSignalEndpointUrl(baseUrl, "/api/v1/check"), {
|
||||
method: "GET",
|
||||
timeoutMs,
|
||||
});
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function openSignalEventStream(
|
||||
url: URL,
|
||||
abortSignal?: AbortSignal,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<{ response: IncomingMessage; cleanup: () => void }> {
|
||||
assertSignalHttpProtocol(url, "SSE");
|
||||
if (abortSignal?.aborted) {
|
||||
throw createSignalSseAbortError();
|
||||
}
|
||||
|
||||
const client = url.protocol === "https:" ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let response: IncomingMessage | undefined;
|
||||
let onAbort: () => void = () => {};
|
||||
const effectiveTimeoutMs = normalizeSignalSseTimeoutMs(timeoutMs);
|
||||
const headerDeadline =
|
||||
effectiveTimeoutMs === null
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
const error = new Error(
|
||||
`Signal SSE connection timed out after ${effectiveTimeoutMs}ms`,
|
||||
);
|
||||
response?.destroy(error);
|
||||
request.destroy(error);
|
||||
rejectOnce(error);
|
||||
}, effectiveTimeoutMs);
|
||||
headerDeadline?.unref?.();
|
||||
const cleanup = () => {
|
||||
if (headerDeadline) {
|
||||
clearTimeout(headerDeadline);
|
||||
}
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const rejectOnce = (error: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(toLintErrorObject(error, "Non-Error rejection"));
|
||||
};
|
||||
const request: ClientRequest = client.request(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
if (status < 200 || status >= 300) {
|
||||
res.resume();
|
||||
rejectOnce(new Error(`Signal SSE failed (${status} ${res.statusMessage || "error"})`));
|
||||
return;
|
||||
}
|
||||
if (settled) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
if (headerDeadline) {
|
||||
clearTimeout(headerDeadline);
|
||||
}
|
||||
settled = true;
|
||||
response = res;
|
||||
resolve({ response: res, cleanup });
|
||||
},
|
||||
);
|
||||
onAbort = () => {
|
||||
const error = createSignalSseAbortError();
|
||||
response?.destroy(error);
|
||||
request.destroy(error);
|
||||
rejectOnce(error);
|
||||
};
|
||||
|
||||
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
request.on("error", rejectOnce);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamSignalEvents(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
onEvent: (event: SignalSseEvent) => void;
|
||||
}): Promise<void> {
|
||||
const url = resolveSignalEndpointUrl(params.baseUrl, "/api/v1/events");
|
||||
if (params.account) {
|
||||
url.searchParams.set("account", params.account);
|
||||
}
|
||||
|
||||
const { response, cleanup } = await openSignalEventStream(
|
||||
url,
|
||||
params.abortSignal,
|
||||
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let bufferedBytes = 0;
|
||||
let currentEvent: SignalSseEvent = {};
|
||||
let currentEventDataBytes = 0;
|
||||
|
||||
const flushEvent = () => {
|
||||
if (!currentEvent.data && !currentEvent.event && !currentEvent.id) {
|
||||
return;
|
||||
}
|
||||
params.onEvent({
|
||||
event: currentEvent.event,
|
||||
data: currentEvent.data,
|
||||
id: currentEvent.id,
|
||||
});
|
||||
currentEvent = {};
|
||||
currentEventDataBytes = 0;
|
||||
};
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (line === "") {
|
||||
flushEvent();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) {
|
||||
return;
|
||||
}
|
||||
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") {
|
||||
const segment = currentEvent.data ? `\n${value}` : value;
|
||||
currentEventDataBytes += Buffer.byteLength(segment, "utf8");
|
||||
if (currentEventDataBytes > MAX_SIGNAL_SSE_EVENT_DATA_BYTES) {
|
||||
throw new Error("Signal SSE event data exceeded size limit");
|
||||
}
|
||||
currentEvent.data = currentEvent.data ? `${currentEvent.data}${segment}` : segment;
|
||||
} else if (field === "id") {
|
||||
currentEvent.id = value;
|
||||
}
|
||||
};
|
||||
|
||||
const drainCompleteLines = () => {
|
||||
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);
|
||||
}
|
||||
processLine(line);
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
}
|
||||
bufferedBytes = Buffer.byteLength(buffer, "utf8");
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const chunk of response as AsyncIterable<Buffer | string>) {
|
||||
const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
||||
bufferedBytes += value.byteLength;
|
||||
if (bufferedBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) {
|
||||
throw new Error("Signal SSE buffer exceeded size limit");
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
drainCompleteLines();
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
buffer += tail;
|
||||
bufferedBytes = Buffer.byteLength(buffer, "utf8");
|
||||
}
|
||||
if (bufferedBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) {
|
||||
throw new Error("Signal SSE buffer exceeded size limit");
|
||||
}
|
||||
drainCompleteLines();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
flushEvent();
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
148
extensions/signal/src/config-schema.test.ts
Normal file
148
extensions/signal/src/config-schema.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// Signal tests cover config schema plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SignalConfigSchema } from "../config-api.js";
|
||||
|
||||
function expectValidSignalConfig(config: unknown) {
|
||||
const res = SignalConfigSchema.safeParse(config);
|
||||
expect(res.success).toBe(true);
|
||||
}
|
||||
|
||||
function expectInvalidSignalConfig(config: unknown) {
|
||||
const res = SignalConfigSchema.safeParse(config);
|
||||
expect(res.success).toBe(false);
|
||||
if (res.success) {
|
||||
throw new Error("expected Signal config to be invalid");
|
||||
}
|
||||
return res.error.issues;
|
||||
}
|
||||
|
||||
describe("signal groups schema", () => {
|
||||
it('rejects dmPolicy="open" without allowFrom "*"', () => {
|
||||
const issues = expectInvalidSignalConfig({
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["+15555550123"],
|
||||
});
|
||||
|
||||
expect(issues[0]?.path.join(".")).toBe("allowFrom");
|
||||
});
|
||||
|
||||
it('accepts dmPolicy="open" with allowFrom "*"', () => {
|
||||
const res = SignalConfigSchema.safeParse({ dmPolicy: "open", allowFrom: ["*"] });
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) {
|
||||
expect(res.data.dmPolicy).toBe("open");
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults dm/group policy", () => {
|
||||
const res = SignalConfigSchema.safeParse({});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) {
|
||||
expect(res.data.dmPolicy).toBe("pairing");
|
||||
expect(res.data.groupPolicy).toBe("allowlist");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts historyLimit", () => {
|
||||
const res = SignalConfigSchema.safeParse({ historyLimit: 6 });
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) {
|
||||
expect(res.data.historyLimit).toBe(6);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts textChunkLimit", () => {
|
||||
const res = SignalConfigSchema.safeParse({
|
||||
enabled: true,
|
||||
textChunkLimit: 2222,
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) {
|
||||
expect(res.data.textChunkLimit).toBe(2222);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts accountUuid for loop protection", () => {
|
||||
expectValidSignalConfig({
|
||||
accountUuid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts top-level and per-account aliases", () => {
|
||||
expectValidSignalConfig({
|
||||
aliases: {
|
||||
me: "+15551234567",
|
||||
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
aliases: {
|
||||
jane: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts channel apiMode", () => {
|
||||
for (const apiMode of ["auto", "native", "container"]) {
|
||||
expectValidSignalConfig({ apiMode });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects per-account apiMode", () => {
|
||||
const issues = expectInvalidSignalConfig({
|
||||
accounts: {
|
||||
primary: {
|
||||
apiMode: "container",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(issues.map((issue) => issue.path.join("."))).toContain("accounts.primary");
|
||||
});
|
||||
|
||||
it("accepts top-level group overrides", () => {
|
||||
expectValidSignalConfig({
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
},
|
||||
"+1234567890": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts per-account group overrides", () => {
|
||||
expectValidSignalConfig({
|
||||
accounts: {
|
||||
primary: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown keys in group entries", () => {
|
||||
const issues = expectInvalidSignalConfig({
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
nope: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(issues.map((issue) => issue.path.join("."))).toEqual(["groups.*"]);
|
||||
});
|
||||
});
|
||||
7
extensions/signal/src/config-schema.ts
Normal file
7
extensions/signal/src/config-schema.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Signal helper module supports config schema behavior.
|
||||
import { buildChannelConfigSchema, SignalConfigSchema } from "../config-api.js";
|
||||
import { signalChannelConfigUiHints } from "./config-ui-hints.js";
|
||||
|
||||
export const SignalChannelConfigSchema = buildChannelConfigSchema(SignalConfigSchema, {
|
||||
uiHints: signalChannelConfigUiHints,
|
||||
});
|
||||
25
extensions/signal/src/config-ui-hints.ts
Normal file
25
extensions/signal/src/config-ui-hints.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Signal helper module supports config ui hints behavior.
|
||||
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.",
|
||||
},
|
||||
configPath: {
|
||||
label: "Signal CLI Config Path",
|
||||
help: "Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path.",
|
||||
},
|
||||
} satisfies Record<string, ChannelConfigUiHint>;
|
||||
948
extensions/signal/src/core.test.ts
Normal file
948
extensions/signal/src/core.test.ts
Normal file
@@ -0,0 +1,948 @@
|
||||
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
// Signal tests cover core plugin behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalPlugin } from "./channel.js";
|
||||
import * as clientModule from "./client-adapter.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 {
|
||||
createSignalCliPathTextInput,
|
||||
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);
|
||||
const result = await signalPlugin.status!.probeAccount!(params);
|
||||
|
||||
expect(result.ok).toBe(expected.ok);
|
||||
expect(result.status).toBe(expected.status);
|
||||
expect(result.error).toBe(expected.error);
|
||||
expect(result.version).toBe(expected.version);
|
||||
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("does not show a second missing-binary note before the cliPath prompt", () => {
|
||||
const input = createSignalCliPathTextInput(async () => true);
|
||||
|
||||
expect(input.helpLines).toBeUndefined();
|
||||
expect(input.helpTitle).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signal outbound", () => {
|
||||
it("resolves aliases through the message target resolver", async () => {
|
||||
const resolved = await signalPlugin.messaging?.targetResolver?.resolveTarget?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
ops: "signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
input: "signal:ops",
|
||||
normalized: "ops",
|
||||
preferredKind: "group",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
to: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
kind: "group",
|
||||
display: "ops",
|
||||
source: "directory",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves aliases through sync outbound target resolution", () => {
|
||||
const resolved = signalPlugin.outbound?.resolveTarget?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
me: "+15551234567",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
to: "signal:me",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ ok: true, to: "+15551234567" });
|
||||
});
|
||||
|
||||
it("keeps Signal outbound text sanitization enabled", () => {
|
||||
expect(
|
||||
signalPlugin.outbound?.sanitizeText?.({
|
||||
text: "<think>private reasoning</think>\nVisible answer",
|
||||
payload: { text: "Visible answer" },
|
||||
}),
|
||||
).toBe("Visible answer");
|
||||
});
|
||||
|
||||
it("resolves aliases before durable Signal message sends", async () => {
|
||||
const send = vi.fn(async () => ({
|
||||
messageId: "signal-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "signal", messageId: "signal-1" }],
|
||||
kind: "text",
|
||||
}),
|
||||
}));
|
||||
|
||||
await signalPlugin.message?.send?.text?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
me: "+15551234567",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
to: "signal:me",
|
||||
text: "approval",
|
||||
deps: { signal: send },
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"+15551234567",
|
||||
"approval",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves aliases before formatted Signal sends", async () => {
|
||||
const send = vi.fn(async () => ({
|
||||
messageId: "signal-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "signal", messageId: "signal-1" }],
|
||||
kind: "text",
|
||||
}),
|
||||
}));
|
||||
|
||||
await signalPlugin.outbound?.sendFormattedText?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
to: "signal:ops",
|
||||
text: "approval",
|
||||
deps: { signal: send },
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
"approval",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a formatted Signal chunk before a later chunk fails", async () => {
|
||||
const send = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "signal-1" })
|
||||
.mockRejectedValueOnce(new Error("second Signal chunk failed"));
|
||||
const onDeliveryResult = vi.fn();
|
||||
|
||||
await expect(
|
||||
signalPlugin.outbound?.sendFormattedText?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "+15551234567",
|
||||
text: "a".repeat(5000),
|
||||
deps: { signal: send },
|
||||
onDeliveryResult,
|
||||
}),
|
||||
).rejects.toThrow("second Signal chunk failed");
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(onDeliveryResult).toHaveBeenCalledTimes(1);
|
||||
expect(onDeliveryResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel: "signal", messageId: "signal-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves aliases before formatted Signal media sends", async () => {
|
||||
const send = vi.fn(async () => ({
|
||||
messageId: "signal-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "signal", messageId: "signal-1" }],
|
||||
kind: "media",
|
||||
}),
|
||||
}));
|
||||
|
||||
await signalPlugin.outbound?.sendFormattedMedia?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
to: "signal:ops",
|
||||
text: "approval",
|
||||
mediaUrl: "file:///tmp/signal-proof.png",
|
||||
deps: { signal: send },
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
"approval",
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
mediaUrl: "file:///tmp/signal-proof.png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns clear outbound errors for recursive aliases", () => {
|
||||
const resolved = signalPlugin.outbound?.resolveTarget?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
home: "signal:me",
|
||||
me: "home",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
to: "signal:home",
|
||||
});
|
||||
|
||||
expect(resolved?.ok).toBe(false);
|
||||
if (resolved?.ok === false) {
|
||||
expect(resolved.error.message).toBe(
|
||||
'Signal alias "home" resolves recursively through "home".',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns target resolver misses for recursive aliases", async () => {
|
||||
const resolved = await signalPlugin.messaging?.targetResolver?.resolveTarget?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
home: "signal:me",
|
||||
me: "home",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
input: "signal:home",
|
||||
normalized: "home",
|
||||
preferredKind: "user",
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("returns clear outbound errors for recursive defaultTo aliases", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
home: "signal:me",
|
||||
me: "home",
|
||||
},
|
||||
defaultTo: "signal:home",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const defaultTo = signalPlugin.config.resolveDefaultTo?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(defaultTo).toBe("signal:home");
|
||||
|
||||
const resolved = signalPlugin.outbound?.resolveTarget?.({
|
||||
cfg,
|
||||
to: defaultTo,
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolved?.ok).toBe(false);
|
||||
if (resolved?.ok === false) {
|
||||
expect(resolved.error.message).toBe(
|
||||
'Signal alias "home" resolves recursively through "home".',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("builds canonical session routes for aliases", async () => {
|
||||
const route = await signalPlugin.messaging?.resolveOutboundSessionRoute?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
agentId: "main",
|
||||
target: "signal:ops",
|
||||
resolvedTarget: {
|
||||
to: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
kind: "group",
|
||||
source: "directory",
|
||||
},
|
||||
});
|
||||
|
||||
expect(route?.to).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=");
|
||||
expect(route?.baseSessionKey).toContain(
|
||||
"signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists configured aliases through the Signal directory", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
aliases: {
|
||||
me: "+15551234567",
|
||||
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await expect(
|
||||
signalPlugin.directory?.listPeers?.({ cfg, query: "me", runtime: {} as never }),
|
||||
).resolves.toEqual([{ kind: "user", id: "+15551234567", name: "me" }]);
|
||||
await expect(
|
||||
signalPlugin.directory?.listGroups?.({ cfg, query: "ops", runtime: {} as never }),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
kind: "group",
|
||||
id: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
|
||||
name: "ops",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("preserves the local approval prompt suppressor through attached-result composition", () => {
|
||||
const suppressor = signalPlugin.outbound?.shouldSuppressLocalPayloadPrompt;
|
||||
if (!suppressor) {
|
||||
throw new Error("signal outbound approval suppressor unavailable");
|
||||
}
|
||||
|
||||
expect(
|
||||
suppressor({
|
||||
cfg: {
|
||||
channels: {
|
||||
signal: {
|
||||
enabled: true,
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
payload: {
|
||||
text: "Approval required.",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "exec-1",
|
||||
approvalSlug: "exec-1",
|
||||
approvalKind: "exec",
|
||||
sessionKey: "agent:main:signal:+15551230000",
|
||||
},
|
||||
},
|
||||
},
|
||||
hint: {
|
||||
kind: "approval-pending",
|
||||
approvalKind: "exec",
|
||||
nativeRouteActive: true,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("registers structured approval payloads for reactions after delivery", async () => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15550009999",
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-after-delivery",
|
||||
approvalSlug: "exec-aft",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const rendered = await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload,
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
});
|
||||
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
|
||||
await signalPlugin.outbound?.afterDeliverPayload?.({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: rendered!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000099",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000099",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-after-delivery",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders reaction hints only from structured approval payloads", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15550009999",
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-rendered-approval",
|
||||
approvalSlug: "exec-ren",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
});
|
||||
const rendered = await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload,
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
expect(
|
||||
await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload: {
|
||||
text: [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-rendered-approval",
|
||||
"",
|
||||
"Reply with: /approve exec-rendered-approval allow-once|deny",
|
||||
].join("\n"),
|
||||
presentation: payload.presentation,
|
||||
},
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
const send = vi.fn(async (_to: string, _text: string, opts: { mediaUrl?: string } = {}) => {
|
||||
const messageId = opts.mediaUrl ? "signal-media-1" : "signal-text-1";
|
||||
return {
|
||||
messageId,
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "signal", messageId }],
|
||||
kind: opts.mediaUrl ? "media" : "text",
|
||||
}),
|
||||
};
|
||||
});
|
||||
const deps = { signal: send };
|
||||
|
||||
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "signal",
|
||||
adapter: signalPlugin.message!,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await signalPlugin.message?.send?.text?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "signal:+15555550123",
|
||||
text: "hello",
|
||||
deps,
|
||||
} as Parameters<NonNullable<typeof signalPlugin.message.send.text>>[0] & {
|
||||
deps: typeof deps;
|
||||
});
|
||||
expect(send).toHaveBeenCalledWith("+15555550123", "hello", {
|
||||
cfg: {},
|
||||
maxBytes: undefined,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["signal-text-1"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await signalPlugin.message?.send?.media?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "signal:+15555550123",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
deps,
|
||||
} as Parameters<NonNullable<typeof signalPlugin.message.send.media>>[0] & {
|
||||
deps: typeof deps;
|
||||
});
|
||||
expect(send).toHaveBeenCalledWith("+15555550123", "image", {
|
||||
cfg: {},
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
maxBytes: undefined,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["signal-media-1"]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(proofResults).toEqual([
|
||||
{ capability: "text", status: "verified" },
|
||||
{ capability: "media", status: "verified" },
|
||||
{ capability: "poll", status: "not_declared" },
|
||||
{ capability: "payload", status: "not_declared" },
|
||||
{ capability: "silent", status: "not_declared" },
|
||||
{ capability: "replyTo", status: "not_declared" },
|
||||
{ capability: "thread", status: "not_declared" },
|
||||
{ capability: "nativeQuote", status: "not_declared" },
|
||||
{ capability: "messageSendingHooks", status: "not_declared" },
|
||||
{ capability: "batch", status: "not_declared" },
|
||||
{ capability: "reconcileUnknownSend", status: "not_declared" },
|
||||
{ capability: "afterSendSuccess", status: "not_declared" },
|
||||
{ capability: "afterCommit", status: "not_declared" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifySignalCliLogLine", () => {
|
||||
it("treats INFO/DEBUG as log", () => {
|
||||
expect(classifySignalCliLogLine("INFO DaemonCommand - Started")).toBe("log");
|
||||
expect(classifySignalCliLogLine("DEBUG Something")).toBe("log");
|
||||
});
|
||||
|
||||
it("treats routine warnings as logs and errors as error state", () => {
|
||||
expect(classifySignalCliLogLine("WARN Something")).toBe("log");
|
||||
expect(classifySignalCliLogLine("WARNING Something")).toBe("log");
|
||||
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", "*"]);
|
||||
});
|
||||
});
|
||||
48
extensions/signal/src/daemon.test.ts
Normal file
48
extensions/signal/src/daemon.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Signal tests cover daemon plugin behavior.
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { testApi } from "./daemon.js";
|
||||
|
||||
describe("signal daemon args", () => {
|
||||
it("expands home-relative configPath before passing it to signal-cli", () => {
|
||||
expect(
|
||||
testApi.buildDaemonArgs({
|
||||
cliPath: "signal-cli",
|
||||
configPath: "~/.openclaw/signal-cli",
|
||||
httpHost: "127.0.0.1",
|
||||
httpPort: 8080,
|
||||
}),
|
||||
).toEqual([
|
||||
"--config",
|
||||
path.join(os.homedir(), ".openclaw/signal-cli"),
|
||||
"daemon",
|
||||
"--http",
|
||||
"127.0.0.1:8080",
|
||||
"--no-receive-stdout",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signal daemon log classification", () => {
|
||||
it("keeps routine signal-cli warnings out of error state", () => {
|
||||
expect(
|
||||
testApi.classifySignalCliLogLine(
|
||||
"WARN ManagerImpl - No profile name set. When sending a message it's recommended to set a profile name.",
|
||||
),
|
||||
).toBe("log");
|
||||
});
|
||||
|
||||
it("keeps recoverable prekey decrypt receive failures out of error state", () => {
|
||||
expect(
|
||||
testApi.classifySignalCliLogLine(
|
||||
"receive exception: org.signal.libsignal.protocol.InvalidMessageException: invalid PreKey message: decryption failed",
|
||||
),
|
||||
).toBe("log");
|
||||
});
|
||||
|
||||
it("still surfaces signal-cli failures as errors", () => {
|
||||
expect(testApi.classifySignalCliLogLine("ERROR DaemonCommand - startup failed")).toBe("error");
|
||||
expect(testApi.classifySignalCliLogLine("SEVERE Manager - database exception")).toBe("error");
|
||||
});
|
||||
});
|
||||
181
extensions/signal/src/daemon.ts
Normal file
181
extensions/signal/src/daemon.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// Signal plugin module implements daemon behavior.
|
||||
import { spawn } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
type SignalDaemonOpts = {
|
||||
cliPath: string;
|
||||
configPath?: 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"})`;
|
||||
}
|
||||
|
||||
function isRecoverableSignalCliReceiveException(line: string): boolean {
|
||||
return /\breceive exception:\s+.*\binvalid PreKey message:\s+decryption failed\b/i.test(line);
|
||||
}
|
||||
|
||||
export function classifySignalCliLogLine(line: string): "log" | "error" | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
// signal-cli commonly writes routine logs and warnings to stderr; only error-like lines should
|
||||
// update channel failure state. Recoverable receive decrypt failures are noisy but non-fatal.
|
||||
if (/\bERROR\b/.test(trimmed)) {
|
||||
return "error";
|
||||
}
|
||||
if (isRecoverableSignalCliReceiveException(trimmed)) {
|
||||
return "log";
|
||||
}
|
||||
// Some signal-cli failures are not tagged with 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 resolveSignalCliConfigPath(raw: string): string {
|
||||
const value = raw.trim();
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildDaemonArgs(opts: SignalDaemonOpts): string[] {
|
||||
const args: string[] = [];
|
||||
if (opts.configPath?.trim()) {
|
||||
args.push("--config", resolveSignalCliConfigPath(opts.configPath));
|
||||
}
|
||||
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);
|
||||
// The executable is operator-selected or setup-discovered signal-cli.
|
||||
// Runtime message content only flows through the daemon HTTP API, not argv.
|
||||
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");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const testApi = {
|
||||
buildDaemonArgs,
|
||||
classifySignalCliLogLine,
|
||||
resolveSignalCliConfigPath,
|
||||
} as const;
|
||||
418
extensions/signal/src/format.chunking.test.ts
Normal file
418
extensions/signal/src/format.chunking.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
// Signal tests cover format.chunking plugin behavior.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SignalTextChunk = ReturnType<typeof markdownToSignalTextChunks>[number];
|
||||
type SignalTextStyle = SignalTextChunk["styles"][number];
|
||||
|
||||
function requireChunkWithStyle(
|
||||
chunks: ReturnType<typeof markdownToSignalTextChunks>,
|
||||
styleName: SignalTextStyle["style"],
|
||||
): SignalTextChunk {
|
||||
const chunk = chunks.find((candidate) =>
|
||||
candidate.styles.some((style) => style.style === styleName),
|
||||
);
|
||||
if (!chunk) {
|
||||
throw new Error(`chunk with ${styleName} style missing`);
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function requireStyle(chunk: SignalTextChunk, styleName: SignalTextStyle["style"]) {
|
||||
const style = chunk.styles.find((candidate) => candidate.style === styleName);
|
||||
if (!style) {
|
||||
throw new Error(`${styleName} style missing`);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
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).toStrictEqual([]);
|
||||
});
|
||||
|
||||
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.map((style) => style.style)).toContain("BOLD");
|
||||
// The bold style should start at position 0 in the first chunk
|
||||
const boldStyle = requireStyle(firstChunk, "BOLD");
|
||||
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"));
|
||||
if (!chunkWithBold) {
|
||||
throw new Error("chunk containing bold text missing");
|
||||
}
|
||||
expect(chunkWithBold.styles.map((style) => style.style)).toContain("BOLD");
|
||||
|
||||
// The bold style should have chunk-local offset (not original text offset)
|
||||
const boldStyle = requireStyle(chunkWithBold, "BOLD");
|
||||
// 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 = requireChunkWithStyle(chunks, "BOLD");
|
||||
|
||||
// Verify the bold style is valid within its chunk
|
||||
const boldStyle = requireStyle(chunkWithBold, "BOLD");
|
||||
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 = requireStyle(firstChunk, "BOLD");
|
||||
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.map((style) => style.style)).toContain("BOLD");
|
||||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("treats Infinity as unbounded for media captions", () => {
|
||||
const markdown = "Here's **another** photo from today's walk.";
|
||||
|
||||
const chunks = markdownToSignalTextChunks(markdown, Number.POSITIVE_INFINITY);
|
||||
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]?.text).toBe("Here's another photo from today's walk.");
|
||||
expect(chunks[0]?.styles.map((style) => style.style)).toContain("BOLD");
|
||||
});
|
||||
|
||||
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 = requireChunkWithStyle(chunks, "SPOILER");
|
||||
|
||||
const spoilerStyle = requireStyle(chunkWithSpoiler, "SPOILER");
|
||||
expect(spoilerStyle.start).toBeGreaterThanOrEqual(0);
|
||||
expect(spoilerStyle.start + spoilerStyle.length).toBeLessThanOrEqual(
|
||||
chunkWithSpoiler.text.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
144
extensions/signal/src/format.test.ts
Normal file
144
extensions/signal/src/format.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// Signal tests cover format plugin behavior.
|
||||
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).toStrictEqual([]);
|
||||
});
|
||||
|
||||
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).toStrictEqual([]);
|
||||
});
|
||||
|
||||
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).toStrictEqual([{ 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).toStrictEqual([{ 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).toStrictEqual([{ 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,}/);
|
||||
});
|
||||
});
|
||||
});
|
||||
268
extensions/signal/src/format.ts
Normal file
268
extensions/signal/src/format.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// Signal helper module supports format behavior.
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
markdownToIR,
|
||||
type MarkdownIR,
|
||||
type MarkdownStyle,
|
||||
renderMarkdownIRChunksWithinLimit,
|
||||
} from "openclaw/plugin-sdk/text-chunking";
|
||||
|
||||
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);
|
||||
}
|
||||
116
extensions/signal/src/identity.ts
Normal file
116
extensions/signal/src/identity.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// Signal plugin module implements identity behavior.
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-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;
|
||||
});
|
||||
}
|
||||
33
extensions/signal/src/inbound-context.contract.test.ts
Normal file
33
extensions/signal/src/inbound-context.contract.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Signal tests cover inbound context.contract plugin behavior.
|
||||
import { expectChannelInboundContextContract } from "openclaw/plugin-sdk/channel-contract-testing";
|
||||
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("Signal inbound context contract", () => {
|
||||
it("keeps inbound context finalized", () => {
|
||||
const ctx = finalizeInboundContext({
|
||||
Body: "Alice: hi",
|
||||
BodyForAgent: "hi",
|
||||
RawBody: "hi",
|
||||
CommandBody: "hi",
|
||||
BodyForCommands: "hi",
|
||||
From: "group:g1",
|
||||
To: "group:g1",
|
||||
SessionKey: "agent:main:signal:group:g1",
|
||||
AccountId: "default",
|
||||
ChatType: "group",
|
||||
ConversationLabel: "Alice",
|
||||
GroupSubject: "Test Group",
|
||||
SenderName: "Alice",
|
||||
SenderId: "+15550001111",
|
||||
Provider: "signal",
|
||||
Surface: "signal",
|
||||
MessageSid: "1700000000000",
|
||||
OriginatingChannel: "signal",
|
||||
OriginatingTo: "group:g1",
|
||||
CommandAuthorized: true,
|
||||
});
|
||||
|
||||
expectChannelInboundContextContract(ctx);
|
||||
});
|
||||
});
|
||||
477
extensions/signal/src/install-signal-cli.test.ts
Normal file
477
extensions/signal/src/install-signal-cli.test.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
// Signal tests cover install signal cli plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import * as tar from "tar";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReleaseAsset } from "./install-signal-cli.js";
|
||||
|
||||
const { fetchWithSsrFGuardMock, resolveBrewExecutableMock, runPluginCommandWithTimeoutMock } =
|
||||
vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
resolveBrewExecutableMock: vi.fn(),
|
||||
runPluginCommandWithTimeoutMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/setup-tools")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveBrewExecutable: resolveBrewExecutableMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/run-command", () => ({
|
||||
runPluginCommandWithTimeout: runPluginCommandWithTimeoutMock,
|
||||
}));
|
||||
|
||||
const {
|
||||
downloadToFile,
|
||||
extractSignalCliArchive,
|
||||
installSignalCli,
|
||||
installSignalCliFromRelease,
|
||||
looksLikeArchive,
|
||||
pickAsset,
|
||||
} = await import("./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",
|
||||
},
|
||||
];
|
||||
|
||||
function okDownloadResponse(body: BodyInit, init: ResponseInit = {}) {
|
||||
return {
|
||||
response: new Response(body, { status: 200, ...init }),
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function withTempFile(run: (filePath: string) => Promise<void>) {
|
||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-download-"));
|
||||
try {
|
||||
await run(path.join(workDir, "signal-cli.tgz"));
|
||||
} finally {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
resolveBrewExecutableMock.mockReset();
|
||||
runPluginCommandWithTimeoutMock.mockReset();
|
||||
});
|
||||
|
||||
function requireAsset(asset: ReleaseAsset | undefined, label: string): ReleaseAsset {
|
||||
if (!asset) {
|
||||
throw new Error(`expected release asset for ${label}`);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
throw new Error(`expected ${targetPath} to be missing`);
|
||||
} catch (error) {
|
||||
expect((error as { code?: string }).code).toBe("ENOENT");
|
||||
}
|
||||
}
|
||||
|
||||
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 = requireAsset(pickAsset(SAMPLE_ASSETS, "linux", "x64"), "linux x64");
|
||||
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 = requireAsset(pickAsset(SAMPLE_ASSETS, "darwin", "arm64"), "darwin arm64");
|
||||
expect(result.name).toContain("macOS-native");
|
||||
});
|
||||
|
||||
it("selects the macOS-native asset on x64", () => {
|
||||
const result = requireAsset(pickAsset(SAMPLE_ASSETS, "darwin", "x64"), "darwin x64");
|
||||
expect(result.name).toContain("macOS-native");
|
||||
});
|
||||
|
||||
it("does not fall back to Linux client archives when macOS assets are absent", () => {
|
||||
const currentUpstreamAssets: ReleaseAsset[] = [
|
||||
{
|
||||
name: "signal-cli-0.14.5-Linux-client.tar.gz",
|
||||
browser_download_url: "https://example.com/linux-client.tar.gz",
|
||||
},
|
||||
{
|
||||
name: "signal-cli-0.14.5-Linux-native.tar.gz",
|
||||
browser_download_url: "https://example.com/linux-native.tar.gz",
|
||||
},
|
||||
{
|
||||
name: "signal-cli-0.14.5.tar.gz",
|
||||
browser_download_url: "https://example.com/jvm.tar.gz",
|
||||
},
|
||||
];
|
||||
|
||||
expect(pickAsset(currentUpstreamAssets, "darwin", "arm64")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("win32", () => {
|
||||
it("selects the Windows-native asset", () => {
|
||||
const result = requireAsset(pickAsset(SAMPLE_ASSETS, "win32", "x64"), "win32 x64");
|
||||
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 = requireAsset(
|
||||
pickAsset(SAMPLE_ASSETS, "freebsd" as NodeJS.Platform, "x64"),
|
||||
"unknown platform",
|
||||
);
|
||||
expect(result.name).toMatch(/\.tar\.gz$/);
|
||||
});
|
||||
|
||||
it("never selects .asc signature files", () => {
|
||||
const result = requireAsset(pickAsset(SAMPLE_ASSETS, "linux", "x64"), "linux x64");
|
||||
expect(result.name).not.toMatch(/\.asc$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadToFile", () => {
|
||||
it("downloads through the SSRF guard with an explicit timeout", async () => {
|
||||
const fetchResult = okDownloadResponse("archive");
|
||||
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
|
||||
|
||||
await withTempFile(async (filePath) => {
|
||||
await downloadToFile("https://example.com/signal-cli.tgz", filePath);
|
||||
|
||||
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("archive");
|
||||
});
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
|
||||
url: "https://example.com/signal-cli.tgz",
|
||||
maxRedirects: 5,
|
||||
requireHttps: true,
|
||||
timeoutMs: 5 * 60_000,
|
||||
capture: false,
|
||||
auditContext: "signal-cli-install-archive",
|
||||
});
|
||||
expect(fetchResult.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects declared archives above the download cap", async () => {
|
||||
const fetchResult = okDownloadResponse("archive", {
|
||||
headers: { "content-length": "12" },
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
|
||||
|
||||
await withTempFile(async (filePath) => {
|
||||
await expect(
|
||||
downloadToFile("https://example.com/signal-cli.tgz", filePath, 5, 8),
|
||||
).rejects.toThrow("declared 12");
|
||||
|
||||
await expectPathMissing(filePath);
|
||||
});
|
||||
|
||||
expect(fetchResult.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["1e3", "0x10", `1${"0".repeat(309)}`])(
|
||||
"ignores malformed declared archive lengths: %s",
|
||||
async (contentLength) => {
|
||||
const fetchResult = okDownloadResponse("archive", {
|
||||
headers: { "content-length": contentLength },
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
|
||||
|
||||
await withTempFile(async (filePath) => {
|
||||
await downloadToFile("https://example.com/signal-cli.tgz", filePath, 5, 8);
|
||||
|
||||
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("archive");
|
||||
});
|
||||
|
||||
expect(fetchResult.release).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("aborts streamed archives above the download cap and removes partial files", async () => {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(6));
|
||||
controller.enqueue(new Uint8Array(6));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const fetchResult = okDownloadResponse(body);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
|
||||
|
||||
await withTempFile(async (filePath) => {
|
||||
await expect(
|
||||
downloadToFile("https://example.com/signal-cli.tgz", filePath, 5, 8),
|
||||
).rejects.toThrow("8-byte download cap");
|
||||
|
||||
await expectPathMissing(filePath);
|
||||
});
|
||||
|
||||
expect(fetchResult.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installSignalCliFromRelease", () => {
|
||||
it("returns an installer error when GitHub release metadata is malformed JSON", async () => {
|
||||
const fetchResult = okDownloadResponse("{not json", {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
|
||||
|
||||
const result = await installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "Failed to parse signal-cli release info.",
|
||||
});
|
||||
expect(fetchResult.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds oversized GitHub release metadata and cancels the stream", async () => {
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunkCount = 20; // 20 MiB — over the 16 MiB cap
|
||||
let readCount = 0;
|
||||
let canceled = false;
|
||||
const oversized = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (readCount >= chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
readCount += 1;
|
||||
controller.enqueue(new Uint8Array(chunkSize));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const releaseMock = vi.fn().mockResolvedValue(undefined);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({ response: oversized, release: releaseMock });
|
||||
|
||||
const result = await installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv);
|
||||
|
||||
expect(result).toEqual({ ok: false, error: "Failed to parse signal-cli release info." });
|
||||
expect(canceled).toBe(true);
|
||||
expect(readCount).toBeLessThan(chunkCount);
|
||||
expect(releaseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds the release metadata request with an explicit timeout", async () => {
|
||||
const fetchResult = okDownloadResponse(JSON.stringify({ tag_name: "v0.14.3", assets: [] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
|
||||
|
||||
const result = await installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("No compatible release asset found for this platform.");
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
|
||||
url: "https://api.github.com/repos/AsamK/signal-cli/releases/latest",
|
||||
maxRedirects: 5,
|
||||
requireHttps: true,
|
||||
timeoutMs: 30_000,
|
||||
capture: false,
|
||||
auditContext: "signal-cli-release-info",
|
||||
init: {
|
||||
headers: {
|
||||
"User-Agent": "openclaw",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fetchResult.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installSignalCli", () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
|
||||
function setProcessPlatform(platform: NodeJS.Platform, arch: string) {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: platform });
|
||||
Object.defineProperty(process, "arch", { configurable: true, value: arch });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
Object.defineProperty(process, "arch", { configurable: true, value: originalArch });
|
||||
});
|
||||
|
||||
it("uses Homebrew on macOS instead of downloading the first GitHub release archive", async () => {
|
||||
setProcessPlatform("darwin", "arm64");
|
||||
const brewPrefix = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-brew-"));
|
||||
await fs.mkdir(path.join(brewPrefix, "bin"), { recursive: true });
|
||||
await fs.writeFile(path.join(brewPrefix, "bin", "signal-cli"), "");
|
||||
resolveBrewExecutableMock.mockReturnValue("/opt/homebrew/bin/brew");
|
||||
runPluginCommandWithTimeoutMock
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: `${brewPrefix}\n`, stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "signal-cli 0.14.5\n", stderr: "" });
|
||||
|
||||
try {
|
||||
const result = await installSignalCli({ log: vi.fn() } as unknown as RuntimeEnv);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
cliPath: path.join(brewPrefix, "bin", "signal-cli"),
|
||||
version: "0.14.5",
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(brewPrefix, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectExtractedSignalCli(archivePath: string, extractDir: string) {
|
||||
await extractSignalCliArchive(archivePath, extractDir, 5_000);
|
||||
|
||||
const extracted = await fs.readFile(path.join(extractDir, "root", "signal-cli"), "utf-8");
|
||||
expect(extracted).toBe("bin");
|
||||
}
|
||||
|
||||
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 expectExtractedSignalCli(archivePath, extractDir);
|
||||
});
|
||||
});
|
||||
|
||||
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 expectExtractedSignalCli(archivePath, extractDir);
|
||||
});
|
||||
});
|
||||
});
|
||||
386
extensions/signal/src/install-signal-cli.ts
Normal file
386
extensions/signal/src/install-signal-cli.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
// Signal plugin module implements install signal cli behavior.
|
||||
import { createWriteStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
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 { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-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[];
|
||||
};
|
||||
|
||||
const MAX_SIGNAL_CLI_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
||||
const SIGNAL_CLI_DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
||||
const SIGNAL_CLI_RELEASE_INFO_TIMEOUT_MS = 30_000;
|
||||
const CONTENT_LENGTH_RE = /^\d+$/;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function isNodeReadableStream(value: unknown): value is Readable {
|
||||
return Boolean(value && typeof (value as { pipe?: unknown }).pipe === "function");
|
||||
}
|
||||
|
||||
function chunkByteLength(chunk: unknown): number {
|
||||
if (typeof chunk === "string") {
|
||||
return Buffer.byteLength(chunk);
|
||||
}
|
||||
if (chunk instanceof Uint8Array) {
|
||||
return chunk.byteLength;
|
||||
}
|
||||
return Buffer.byteLength(String(chunk));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/);
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
return byName(/windows|win/) || archives[0];
|
||||
}
|
||||
|
||||
return archives[0];
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export async function downloadToFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
maxRedirects = 5,
|
||||
maxBytes = MAX_SIGNAL_CLI_ARCHIVE_BYTES,
|
||||
): Promise<void> {
|
||||
let completed = false;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
maxRedirects,
|
||||
requireHttps: true,
|
||||
timeoutMs: SIGNAL_CLI_DOWNLOAD_TIMEOUT_MS,
|
||||
capture: false,
|
||||
auditContext: "signal-cli-install-archive",
|
||||
});
|
||||
try {
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status || "?"} downloading file`);
|
||||
}
|
||||
|
||||
const rawLength = response.headers.get("content-length");
|
||||
if (rawLength !== null) {
|
||||
const trimmedLength = rawLength.trim();
|
||||
const declaredLength = CONTENT_LENGTH_RE.test(trimmedLength)
|
||||
? Number(trimmedLength)
|
||||
: Number.NaN;
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
throw new Error(
|
||||
`signal-cli archive exceeds the ${maxBytes}-byte download cap (declared ${declaredLength}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
const body = response.body;
|
||||
const readable = isNodeReadableStream(body) ? body : Readable.fromWeb(body as never);
|
||||
const limiter = new Transform({
|
||||
transform(chunk: unknown, _encoding, callback) {
|
||||
totalBytes += chunkByteLength(chunk);
|
||||
if (totalBytes > maxBytes) {
|
||||
callback(new Error(`signal-cli archive exceeded the ${maxBytes}-byte download cap.`));
|
||||
return;
|
||||
}
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const out = createWriteStream(dest);
|
||||
await pipeline(readable, limiter, out);
|
||||
completed = true;
|
||||
} finally {
|
||||
await release();
|
||||
if (!completed) {
|
||||
await fs.rm(dest, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.platform}/${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}): ${truncateUtf16Safe(result.stderr.trim(), 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export async function installSignalCliFromRelease(
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<SignalInstallResult> {
|
||||
const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest";
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: apiUrl,
|
||||
maxRedirects: 5,
|
||||
requireHttps: true,
|
||||
timeoutMs: SIGNAL_CLI_RELEASE_INFO_TIMEOUT_MS,
|
||||
capture: false,
|
||||
auditContext: "signal-cli-release-info",
|
||||
init: {
|
||||
headers: {
|
||||
"User-Agent": "openclaw",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let payload: ReleaseResponse;
|
||||
try {
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Failed to fetch release info (${response.status})`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
payload = await readProviderJsonResponse<ReleaseResponse>(response, "signal.release-info");
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Failed to parse signal-cli release info.",
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
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. Other platforms use Homebrew instead of guessing from
|
||||
// unrelated release archives.
|
||||
const hasNativeRelease = process.platform === "linux" && process.arch === "x64";
|
||||
|
||||
if (hasNativeRelease) {
|
||||
return installSignalCliFromRelease(runtime);
|
||||
}
|
||||
|
||||
return installSignalCliViaBrew(runtime);
|
||||
}
|
||||
203
extensions/signal/src/message-actions.test.ts
Normal file
203
extensions/signal/src/message-actions.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// Signal tests cover message actions plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
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 ?? [],
|
||||
).toStrictEqual([]);
|
||||
|
||||
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();
|
||||
const expectedOptions = testCase.expectedOptions as {
|
||||
accountId?: string;
|
||||
groupId?: string;
|
||||
targetAuthor?: string;
|
||||
targetAuthorUuid?: string;
|
||||
};
|
||||
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,
|
||||
{
|
||||
cfg: testCase.cfg,
|
||||
accountId: expectedOptions.accountId,
|
||||
groupId: expectedOptions.groupId,
|
||||
targetAuthor: expectedOptions.targetAuthor,
|
||||
targetAuthorUuid: expectedOptions.targetAuthorUuid,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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: "+15559999999", messageId: "123abc", emoji: "✅" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/Invalid messageId/);
|
||||
expect(sendReactionSignalMock).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
signalMessageActions.handleAction?.({
|
||||
channel: "signal",
|
||||
action: "react",
|
||||
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/targetAuthor/);
|
||||
});
|
||||
});
|
||||
189
extensions/signal/src/message-actions.ts
Normal file
189
extensions/signal/src/message-actions.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// Signal plugin module implements message actions behavior.
|
||||
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 { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-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 = parseStrictNonNegativeInteger(messageId);
|
||||
if (timestamp === undefined) {
|
||||
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}.`);
|
||||
},
|
||||
};
|
||||
127
extensions/signal/src/monitor.approval-reply-delivery.test.ts
Normal file
127
extensions/signal/src/monitor.approval-reply-delivery.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageSignal: sendMocks.sendMessageSignal,
|
||||
};
|
||||
});
|
||||
|
||||
const { deliverReplies } = await import("./monitor.js");
|
||||
|
||||
const botAccount = "+15550009999";
|
||||
const approver = "+15551230000";
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: botAccount,
|
||||
allowFrom: [approver],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: approver }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
async function deliverReplyPayload(payload: ReplyPayload) {
|
||||
await deliverReplies({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
target: approver,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
account: botAccount,
|
||||
accountId: "default",
|
||||
runtime: { log: vi.fn() } as never,
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
textLimit: 4000,
|
||||
chunkMode: "length",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Signal monitor approval reply delivery", () => {
|
||||
beforeEach(() => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
sendMocks.sendMessageSignal.mockReset().mockResolvedValue({
|
||||
messageId: "1700000000200",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds reaction hints and registers structured approval replies delivered by the monitor", async () => {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-monitor-structured",
|
||||
approvalSlug: "exec-mon",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf monitor",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
|
||||
await deliverReplyPayload(payload);
|
||||
|
||||
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
|
||||
expect(sentText).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: approver,
|
||||
messageId: "1700000000200",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: botAccount,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-monitor-structured",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: approver,
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bind ordinary monitor replies that quote approval commands", async () => {
|
||||
const payload = {
|
||||
text: [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-monitor-quoted",
|
||||
"",
|
||||
"Reply with: /approve exec-monitor-quoted allow-once|deny",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
await deliverReplyPayload(payload);
|
||||
|
||||
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
|
||||
expect(sentText).not.toContain("React with:");
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: approver,
|
||||
messageId: "1700000000200",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: botAccount,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
261
extensions/signal/src/monitor.tool-result.autostart.test.ts
Normal file
261
extensions/signal/src/monitor.tool-result.autostart.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// Signal tests cover monitor.tool result.autostart plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
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 abortController;
|
||||
}
|
||||
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider({
|
||||
config: config as OpenClawConfig,
|
||||
waitForTransportReady:
|
||||
waitForTransportReadyMock as MonitorSignalProviderOptions["waitForTransportReady"],
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function requireWaitForTransportReadyOptions(): Record<string, unknown> {
|
||||
const [call] = waitForTransportReadyMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected waitForTransportReady call");
|
||||
}
|
||||
const [options] = call;
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new Error("expected waitForTransportReady options");
|
||||
}
|
||||
return options as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectWaitForTransportReadyTimeout(timeoutMs: number) {
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
const options = requireWaitForTransportReadyOptions();
|
||||
expect(options.timeoutMs).toBe(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);
|
||||
const options = requireWaitForTransportReadyOptions();
|
||||
expect(options).toEqual({
|
||||
label: "signal daemon",
|
||||
timeoutMs: 30_000,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
pollIntervalMs: 150,
|
||||
runtime,
|
||||
abortSignal: options.abortSignal,
|
||||
check: options.check,
|
||||
});
|
||||
expect(options.abortSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(typeof options.check).toBe("function");
|
||||
});
|
||||
|
||||
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("passes channels.signal.configPath to signal-cli daemon startup", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig({ configPath: "~/.openclaw/signal-cli" });
|
||||
const abortController = createAutoAbortController();
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(spawnSignalDaemonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
configPath: "~/.openclaw/signal-cli",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits configPath when channels.signal.configPath is blank", async () => {
|
||||
const runtime = createMonitorRuntime();
|
||||
setSignalAutoStartConfig({ configPath: " " });
|
||||
const abortController = createAutoAbortController();
|
||||
|
||||
await runMonitorWithMocks({
|
||||
autoStart: true,
|
||||
baseUrl: SIGNAL_BASE_URL,
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
});
|
||||
|
||||
const [daemonOpts] = spawnSignalDaemonMock.mock.calls[0] ?? [];
|
||||
expect(daemonOpts).toBeDefined();
|
||||
expect(daemonOpts).not.toHaveProperty("configPath");
|
||||
});
|
||||
|
||||
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(toLintErrorObject(params.abortSignal.reason, "Non-Error rejection"));
|
||||
return;
|
||||
}
|
||||
params.abortSignal?.addEventListener(
|
||||
"abort",
|
||||
() =>
|
||||
reject(
|
||||
toLintErrorObject(
|
||||
params.abortSignal?.reason ?? new Error("aborted"),
|
||||
"Non-Error rejection",
|
||||
),
|
||||
),
|
||||
{ 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) | undefined;
|
||||
const exitedPromise = new Promise<SignalDaemonExitEvent>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const stop = vi.fn(() => {
|
||||
if (exited) {
|
||||
return;
|
||||
}
|
||||
exited = true;
|
||||
if (!resolveExit) {
|
||||
throw new Error("Expected signal daemon exit resolver to be initialized");
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Signal tests cover monitor.tool result.pairs uuid only senders uuid allowlist entry plugin behavior.
|
||||
import { Buffer } from "node:buffer";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
config,
|
||||
getSignalToolResultTestMocks,
|
||||
installSignalToolResultTestHooks,
|
||||
setSignalToolResultTestConfig,
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
|
||||
installSignalToolResultTestHooks();
|
||||
const { monitorSignalProvider } = await import("./monitor.js");
|
||||
|
||||
const { replyMock, sendMock, streamMock, signalRpcRequestMock, upsertPairingRequestMock } =
|
||||
getSignalToolResultTestMocks();
|
||||
|
||||
type MonitorSignalProviderOptions = Parameters<typeof monitorSignalProvider>[0];
|
||||
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider(opts);
|
||||
}
|
||||
|
||||
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
||||
const call = mock.mock.calls.at(callIndex);
|
||||
if (!call) {
|
||||
throw new Error(`Expected mock call ${callIndex}`);
|
||||
}
|
||||
return call.at(argIndex);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith({
|
||||
channel: "signal",
|
||||
id: `uuid:${uuid}`,
|
||||
accountId: "default",
|
||||
meta: { name: "Ada" },
|
||||
});
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallArg(sendMock)).toBe(`signal:${uuid}`);
|
||||
const pairingReply = mockCallArg(sendMock, 0, 1);
|
||||
if (typeof pairingReply !== "string") {
|
||||
throw new Error("Expected pairing reply text");
|
||||
}
|
||||
expect(pairingReply).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);
|
||||
expect((mockCallArg(streamMock) as { timeoutMs?: unknown }).timeoutMs).toBe(0);
|
||||
expect((mockCallArg(streamMock, 1) as { timeoutMs?: unknown }).timeoutMs).toBe(0);
|
||||
} finally {
|
||||
randomSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("sizes attachment RPC response caps from mediaMaxMb", async () => {
|
||||
const abortController = new AbortController();
|
||||
const maxBytes = 2 * 1024 * 1024;
|
||||
const expectedMaxResponseBytes = Math.ceil((maxBytes * 4) / 3) + 64 * 1024;
|
||||
|
||||
replyMock.mockResolvedValue({ text: "ok" });
|
||||
signalRpcRequestMock.mockResolvedValue({ data: Buffer.from("hello").toString("base64") });
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
await onEvent({
|
||||
event: "receive",
|
||||
data: JSON.stringify({
|
||||
envelope: {
|
||||
sourceNumber: "+15550001111",
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "",
|
||||
attachments: [{ id: "attachment-1", size: 1_500_000, contentType: "text/plain" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
await monitorSignalProvider({
|
||||
autoStart: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
mediaMaxMb: 2,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"getAttachment",
|
||||
{
|
||||
id: "attachment-1",
|
||||
recipient: "+15550001111",
|
||||
},
|
||||
{
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
timeoutMs: undefined,
|
||||
apiMode: "auto",
|
||||
maxResponseBytes: expectedMaxResponseBytes,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
// Signal tests cover monitor.tool result.sends tool summaries responseprefix plugin behavior.
|
||||
import { expectPairingReplyText } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createSignalToolResultConfig,
|
||||
config,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
247
extensions/signal/src/monitor.tool-result.test-harness.ts
Normal file
247
extensions/signal/src/monitor.tool-result.test-harness.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// Signal plugin module implements monitor.tool result harness behavior.
|
||||
import type { MockFn } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
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 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/runtime-config-snapshot", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("openclaw/plugin-sdk/runtime-config-snapshot")
|
||||
>("openclaw/plugin-sdk/runtime-config-snapshot");
|
||||
return {
|
||||
...actual,
|
||||
getRuntimeConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
|
||||
"openclaw/plugin-sdk/session-store-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
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("./client-adapter.js", () => ({
|
||||
streamSignalEvents: (...args: unknown[]) => streamMock(...args),
|
||||
signalCheck: (...args: unknown[]) => signalCheckMock(...args),
|
||||
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./daemon.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./daemon.js")>();
|
||||
return {
|
||||
...actual,
|
||||
spawnSignalDaemon: (...args: unknown[]) => spawnSignalDaemonMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/system-event-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/system-event-runtime")>(
|
||||
"openclaw/plugin-sdk/system-event-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: Parameters<typeof actual.enqueueSystemEvent>) => {
|
||||
enqueueSystemEventMock(...args);
|
||||
return actual.enqueueSystemEvent(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
|
||||
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/system-event-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();
|
||||
});
|
||||
}
|
||||
640
extensions/signal/src/monitor.ts
Normal file
640
extensions/signal/src/monitor.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
// Signal plugin module implements monitor behavior.
|
||||
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
detectMime,
|
||||
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 { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import {
|
||||
createNonExitingRuntime,
|
||||
type BackoffPolicy,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js";
|
||||
import {
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest, signalCheck } from "./client-adapter.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;
|
||||
channelRuntime?: ChannelRuntimeSurface;
|
||||
autoStart?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
cliPath?: string;
|
||||
configPath?: 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 createSignalMonitorTaskRunner(runtime: RuntimeEnv) {
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
return {
|
||||
runEventTask(task: () => Promise<void>): void {
|
||||
const trackedTask = Promise.resolve()
|
||||
.then(task)
|
||||
.catch((err: unknown) => runtime.error?.(`event handler failed: ${String(err)}`))
|
||||
.finally(() => inFlight.delete(trackedTask));
|
||||
inFlight.add(trackedTask);
|
||||
},
|
||||
async waitForIdle(): Promise<void> {
|
||||
while (inFlight.size > 0) {
|
||||
await Promise.allSettled(inFlight);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SIGNAL_ATTACHMENT_RPC_RESPONSE_HEADROOM_BYTES = 64 * 1024;
|
||||
const SIGNAL_BASE64_OVERHEAD_NUMERATOR = 4;
|
||||
const SIGNAL_BASE64_OVERHEAD_DENOMINATOR = 3;
|
||||
|
||||
function deriveSignalAttachmentRpcMaxResponseBytes(maxBytes: number): number | undefined {
|
||||
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const base64Bytes = Math.ceil(
|
||||
(maxBytes * SIGNAL_BASE64_OVERHEAD_NUMERATOR) / SIGNAL_BASE64_OVERHEAD_DENOMINATOR,
|
||||
);
|
||||
return base64Bytes + SIGNAL_ATTACHMENT_RPC_RESPONSE_HEADROOM_BYTES;
|
||||
}
|
||||
|
||||
async function fetchAttachment(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
apiMode?: "native" | "container" | "auto";
|
||||
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,
|
||||
maxResponseBytes: deriveSignalAttachmentRpcMaxResponseBytes(params.maxBytes),
|
||||
apiMode: params.apiMode,
|
||||
});
|
||||
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 originalFilename = normalizeOptionalString(attachment.filename ?? undefined);
|
||||
const contentType =
|
||||
normalizeOptionalString(attachment.contentType ?? undefined) ??
|
||||
(await detectMime({ buffer, filePath: originalFilename }));
|
||||
const saved = await saveMediaBuffer(
|
||||
buffer,
|
||||
contentType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
originalFilename,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
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 deliveryResults: Array<{
|
||||
channel: "signal";
|
||||
messageId: string;
|
||||
meta: { signalVisibleText: string };
|
||||
}> = [];
|
||||
const deliveredPayload =
|
||||
addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
to: target,
|
||||
payload,
|
||||
targetAuthor: account,
|
||||
}) ?? payload;
|
||||
const reply = resolveSendableOutboundReplyParts(deliveredPayload);
|
||||
const recordDeliveryResult = (
|
||||
result: Awaited<ReturnType<typeof sendMessageSignal>>,
|
||||
visibleText: string,
|
||||
) => {
|
||||
const messageId =
|
||||
typeof result?.messageId === "string" && result.messageId.trim()
|
||||
? result.messageId.trim()
|
||||
: null;
|
||||
if (messageId) {
|
||||
deliveryResults.push({
|
||||
channel: "signal",
|
||||
messageId,
|
||||
meta: { signalVisibleText: visibleText },
|
||||
});
|
||||
}
|
||||
};
|
||||
const delivered = await deliverTextOrMediaReply({
|
||||
payload: deliveredPayload,
|
||||
text: reply.text,
|
||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
chunk,
|
||||
);
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
const visibleText = caption ?? "";
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, visibleText, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
visibleText,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (delivered !== "empty") {
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: target,
|
||||
accountId,
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: deliveryResults,
|
||||
targetAuthor: account,
|
||||
});
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? getRuntimeConfig();
|
||||
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 configuredApiMode = cfg.channels?.signal?.apiMode ?? "auto";
|
||||
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 });
|
||||
const monitorTaskRunner = createSignalMonitorTaskRunner(runtime);
|
||||
let daemonHandle: SignalDaemonHandle | null = null;
|
||||
|
||||
if (autoStart && configuredApiMode === "container") {
|
||||
throw new Error(
|
||||
"channels.signal.autoStart=true is incompatible with channels.signal.apiMode=container",
|
||||
);
|
||||
}
|
||||
|
||||
if (autoStart) {
|
||||
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
|
||||
const configPath =
|
||||
normalizeOptionalString(opts.configPath) ??
|
||||
normalizeOptionalString(accountInfo.config.configPath);
|
||||
const httpHost = opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
|
||||
daemonHandle = spawnSignalDaemon({
|
||||
cliPath,
|
||||
...(configPath ? { configPath } : {}),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
registerChannelRuntimeContext({
|
||||
channelRuntime: opts.channelRuntime,
|
||||
channelId: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
||||
context: isSignalNativeApprovalHandlerConfigured({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
})
|
||||
? {
|
||||
accountId: accountInfo.accountId,
|
||||
baseUrl,
|
||||
account,
|
||||
accountUuid: accountInfo.config.accountUuid,
|
||||
}
|
||||
: null,
|
||||
abortSignal: opts.abortSignal,
|
||||
});
|
||||
|
||||
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: (params) => fetchAttachment({ ...params, apiMode: configuredApiMode }),
|
||||
deliverReplies: (params) => deliverReplies({ ...params, cfg, chunkMode }),
|
||||
resolveSignalReactionTargets,
|
||||
isSignalReactionMessage,
|
||||
shouldEmitSignalReactionNotification,
|
||||
buildSignalReactionSystemEventText,
|
||||
});
|
||||
|
||||
await runSignalSseLoop({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal: daemonLifecycle.abortSignal,
|
||||
runtime,
|
||||
// signal-cli can keep the SSE event endpoint idle until the next inbound event.
|
||||
timeoutMs: 0,
|
||||
apiMode: configuredApiMode,
|
||||
policy: opts.reconnectPolicy,
|
||||
onEvent: (event) => {
|
||||
monitorTaskRunner.runEventTask(() => handleEvent(event));
|
||||
},
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
} catch (err) {
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (opts.abortSignal?.aborted && !daemonExitError) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await monitorTaskRunner.waitForIdle();
|
||||
daemonLifecycle.dispose();
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
daemonLifecycle.stop();
|
||||
}
|
||||
}
|
||||
279
extensions/signal/src/monitor/access-policy.test.ts
Normal file
279
extensions/signal/src/monitor/access-policy.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// Signal tests cover access policy plugin behavior.
|
||||
import type { AccessGroupsConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
|
||||
|
||||
const SIGNAL_GROUP_ID = "signal-group-id";
|
||||
const OTHER_SIGNAL_GROUP_ID = "other-signal-group-id";
|
||||
const SIGNAL_SENDER = {
|
||||
kind: "phone" as const,
|
||||
e164: "+15551230000",
|
||||
raw: "+15551230000",
|
||||
};
|
||||
|
||||
async function resolveGroupAccess(params: {
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
groupId?: string;
|
||||
accessGroups?: AccessGroupsConfig;
|
||||
storeAllowFrom?: string[];
|
||||
}) {
|
||||
const access = await resolveSignalAccessState({
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
groupAllowFrom: params.groupAllowFrom ?? [],
|
||||
sender: SIGNAL_SENDER,
|
||||
groupId: params.groupId,
|
||||
isGroup: true,
|
||||
cfg: accessGroupsConfig(params.accessGroups),
|
||||
readStoreAllowFrom: async () => params.storeAllowFrom ?? [],
|
||||
});
|
||||
return {
|
||||
...access,
|
||||
groupDecision: access.senderAccess,
|
||||
};
|
||||
}
|
||||
|
||||
function accessGroupsConfig(
|
||||
accessGroups: AccessGroupsConfig | undefined,
|
||||
): Pick<OpenClawConfig, "accessGroups"> | undefined {
|
||||
return accessGroups ? { accessGroups } : undefined;
|
||||
}
|
||||
|
||||
describe("resolveSignalAccessState", () => {
|
||||
it("allows group messages when groupAllowFrom contains the inbound Signal group id", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: [SIGNAL_GROUP_ID],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows Signal group target forms in groupAllowFrom", async () => {
|
||||
const groupTargetDecision = await resolveGroupAccess({
|
||||
groupAllowFrom: [`group:${SIGNAL_GROUP_ID}`],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
const signalGroupTargetDecision = await resolveGroupAccess({
|
||||
groupAllowFrom: [`signal:group:${SIGNAL_GROUP_ID}`],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupTargetDecision.groupDecision.decision).toBe("allow");
|
||||
expect(signalGroupTargetDecision.groupDecision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("blocks group messages when groupAllowFrom contains a different Signal group id", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: [OTHER_SIGNAL_GROUP_ID],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("block");
|
||||
});
|
||||
|
||||
it("keeps sender allowlist compatibility for Signal group messages", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: [SIGNAL_SENDER.e164],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("falls back to allowFrom for group sender access when groupAllowFrom is unset", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
allowFrom: [SIGNAL_SENDER.e164],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("does not match group ids against direct-message allowFrom entries", async () => {
|
||||
const { senderAccess } = await resolveSignalAccessState({
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [SIGNAL_GROUP_ID],
|
||||
groupAllowFrom: [],
|
||||
sender: SIGNAL_SENDER,
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
isGroup: false,
|
||||
});
|
||||
|
||||
expect(senderAccess.decision).toBe("block");
|
||||
});
|
||||
|
||||
it("allows direct messages through static message sender access groups", async () => {
|
||||
const { senderAccess } = await resolveSignalAccessState({
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["accessGroup:operators"],
|
||||
groupAllowFrom: [],
|
||||
sender: SIGNAL_SENDER,
|
||||
isGroup: false,
|
||||
cfg: accessGroupsConfig({
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
signal: [SIGNAL_SENDER.e164],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(senderAccess.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows group messages through static message sender access groups", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: ["accessGroup:operators"],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
signal: [SIGNAL_SENDER.e164],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("preserves matched Signal senders in effective group allowlists", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: ["accessGroup:operators"],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
signal: [SIGNAL_SENDER.e164],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("allow");
|
||||
expect(groupDecision.effectiveGroupAllowFrom).toContain(SIGNAL_SENDER.e164);
|
||||
});
|
||||
|
||||
it("allows paired direct senders from the pairing store", async () => {
|
||||
const { senderAccess } = await resolveSignalAccessState({
|
||||
accountId: "default",
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
sender: SIGNAL_SENDER,
|
||||
isGroup: false,
|
||||
readStoreAllowFrom: async () => [SIGNAL_SENDER.e164],
|
||||
});
|
||||
|
||||
expect(senderAccess.decision).toBe("allow");
|
||||
expect(senderAccess.effectiveAllowFrom).toEqual([SIGNAL_SENDER.e164]);
|
||||
});
|
||||
|
||||
it("does not let pairing-store senders satisfy group access", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
groupAllowFrom: [],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
storeAllowFrom: [SIGNAL_SENDER.e164],
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("block");
|
||||
});
|
||||
|
||||
it("does not let group ids in allowFrom satisfy an explicit groupAllowFrom mismatch", async () => {
|
||||
const { groupDecision } = await resolveGroupAccess({
|
||||
allowFrom: [SIGNAL_GROUP_ID],
|
||||
groupAllowFrom: [OTHER_SIGNAL_GROUP_ID],
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
});
|
||||
|
||||
expect(groupDecision.decision).toBe("block");
|
||||
});
|
||||
|
||||
it("keeps sender access allowed while blocking unauthorized group control commands", async () => {
|
||||
const access = await resolveSignalAccessState({
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
sender: SIGNAL_SENDER,
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
isGroup: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(access.senderAccess.decision).toBe("allow");
|
||||
expect(access.commandAccess.authorized).toBe(false);
|
||||
expect(access.commandAccess.shouldBlockControlCommand).toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes group control commands from the shared ingress command gate", async () => {
|
||||
const access = await resolveSignalAccessState({
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [SIGNAL_SENDER.e164],
|
||||
sender: SIGNAL_SENDER,
|
||||
groupId: SIGNAL_GROUP_ID,
|
||||
isGroup: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(access.commandAccess.authorized).toBe(true);
|
||||
expect(access.commandAccess.shouldBlockControlCommand).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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:");
|
||||
});
|
||||
});
|
||||
202
extensions/signal/src/monitor/access-policy.ts
Normal file
202
extensions/signal/src/monitor/access-policy.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Signal plugin module implements access policy behavior.
|
||||
import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
formatSignalSenderId,
|
||||
looksLikeUuid,
|
||||
normalizeSignalAllowRecipient,
|
||||
type SignalSender,
|
||||
} from "../identity.js";
|
||||
|
||||
type SignalDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type SignalGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
const SIGNAL_UUID_KIND = "plugin:signal-uuid" as const;
|
||||
const SIGNAL_GROUP_KIND = "plugin:signal-group" as const;
|
||||
|
||||
function strippedSignalEntry(
|
||||
entry: string,
|
||||
): { trimmed: string; signalStripped: string; lower: string } | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const signalStripped = trimmed.replace(/^signal:/i, "").trim();
|
||||
const lower = signalStripped.toLowerCase();
|
||||
return { trimmed, signalStripped, lower };
|
||||
}
|
||||
|
||||
function normalizeSignalGroupEntry(entry: string): string | null {
|
||||
const parsed = strippedSignalEntry(entry);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const { trimmed, signalStripped, lower } = parsed;
|
||||
if (lower.startsWith("group:")) {
|
||||
const groupId = signalStripped.slice("group:".length).trim();
|
||||
return groupId || null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeSignalUuidEntry(entry: string): string | null {
|
||||
const parsed = strippedSignalEntry(entry);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const { signalStripped, lower } = parsed;
|
||||
if (lower.startsWith("uuid:")) {
|
||||
const raw = signalStripped.slice("uuid:".length).trim();
|
||||
return raw || null;
|
||||
}
|
||||
return looksLikeUuid(signalStripped) ? signalStripped : null;
|
||||
}
|
||||
|
||||
function normalizeSignalPhoneEntry(entry: string): string | null {
|
||||
const parsed = strippedSignalEntry(entry);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return normalizeSignalAllowRecipient(parsed.trimmed) ?? null;
|
||||
}
|
||||
|
||||
const signalIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "stable",
|
||||
normalizeEntry: () => null,
|
||||
aliases: [
|
||||
{
|
||||
key: "phone",
|
||||
kind: "phone",
|
||||
normalizeEntry: normalizeSignalPhoneEntry,
|
||||
normalizeSubject: (value: string) => value,
|
||||
sensitivity: "pii",
|
||||
},
|
||||
{
|
||||
key: "uuid",
|
||||
kind: SIGNAL_UUID_KIND,
|
||||
normalizeEntry: normalizeSignalUuidEntry,
|
||||
normalizeSubject: (value: string) => value,
|
||||
sensitivity: "pii",
|
||||
},
|
||||
{
|
||||
key: "group",
|
||||
kind: SIGNAL_GROUP_KIND,
|
||||
normalizeEntry: normalizeSignalGroupEntry,
|
||||
normalizeSubject: (value: string) => value,
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => entry.trim() === "*",
|
||||
resolveEntryId({ entryIndex, fieldKey }) {
|
||||
return `entry-${entryIndex + 1}:${fieldKey}`;
|
||||
},
|
||||
});
|
||||
|
||||
function signalSubjectInput(params: { sender: SignalSender; groupId?: string }) {
|
||||
return {
|
||||
stableId: formatSignalSenderId(params.sender),
|
||||
aliases: {
|
||||
phone: params.sender.kind === "phone" ? params.sender.e164 : undefined,
|
||||
uuid: params.sender.kind === "uuid" ? params.sender.raw : undefined,
|
||||
group: params.groupId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSignalAccessState(params: {
|
||||
accountId: string;
|
||||
dmPolicy: SignalDmPolicy;
|
||||
groupPolicy: SignalGroupPolicy;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
sender: SignalSender;
|
||||
groupId?: string;
|
||||
isGroup?: boolean;
|
||||
cfg?: Pick<OpenClawConfig, "accessGroups" | "commands">;
|
||||
hasControlCommand?: boolean;
|
||||
readStoreAllowFrom?: () => Promise<string[]>;
|
||||
}) {
|
||||
const isGroup = params.isGroup ?? params.groupId != null;
|
||||
const command =
|
||||
params.hasControlCommand === true
|
||||
? {
|
||||
allowTextCommands: true,
|
||||
directGroupAllowFrom: "effective" as const,
|
||||
}
|
||||
: undefined;
|
||||
const ingress = createChannelIngressResolver({
|
||||
channelId: "signal",
|
||||
accountId: params.accountId,
|
||||
identity: signalIngressIdentity,
|
||||
cfg: params.cfg,
|
||||
...(params.readStoreAllowFrom ? { readStoreAllowFrom: params.readStoreAllowFrom } : {}),
|
||||
useDefaultPairingStore: params.readStoreAllowFrom == null,
|
||||
});
|
||||
return await ingress.message({
|
||||
subject: signalSubjectInput({
|
||||
sender: params.sender,
|
||||
groupId: isGroup ? params.groupId : undefined,
|
||||
}),
|
||||
conversation: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup ? (params.groupId ?? "unknown") : params.sender.raw,
|
||||
},
|
||||
...(isGroup ? { event: { mayPair: false } } : {}),
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: params.groupPolicy,
|
||||
policy: { groupAllowFromFallbackToAllowFrom: true },
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
1980
extensions/signal/src/monitor/event-handler.inbound-context.test.ts
Normal file
1980
extensions/signal/src/monitor/event-handler.inbound-context.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
// Signal tests cover event handler.mention gating plugin behavior.
|
||||
import { buildDispatchInboundCaptureMock } from "openclaw/plugin-sdk/channel-contract-testing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type SignalMsgContext = Pick<MsgContext, "Body" | "WasMentioned"> & {
|
||||
Body?: string;
|
||||
WasMentioned?: boolean;
|
||||
};
|
||||
|
||||
let capturedCtx: SignalMsgContext | undefined;
|
||||
|
||||
function getCapturedCtx() {
|
||||
if (!capturedCtx) {
|
||||
throw new Error("expected captured Signal MsgContext");
|
||||
}
|
||||
return capturedCtx;
|
||||
}
|
||||
|
||||
function getGroupHistoryEntries(
|
||||
groupHistories: Map<string, Array<{ sender?: string; body?: string }>>,
|
||||
groupId = "g1",
|
||||
) {
|
||||
const entries = groupHistories.get(groupId);
|
||||
if (!entries) {
|
||||
throw new Error(`expected pending history for ${groupId}`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
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 = getGroupHistoryEntries(groupHistories);
|
||||
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(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(getCapturedCtx().WasMentioned).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicitly configured Signal groups by group id without a mention", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
cfg: {
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
groupChat: { mentionPatterns: ["@bot"] },
|
||||
},
|
||||
channels: {
|
||||
signal: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["group:g1"],
|
||||
groups: { g1: {} },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["group:g1"],
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(makeGroupEvent({ message: "hello everyone" }));
|
||||
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 = getGroupHistoryEntries(groupHistories);
|
||||
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 = getGroupHistoryEntries(groupHistories);
|
||||
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 = getGroupHistoryEntries(groupHistories);
|
||||
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(getCapturedCtx().Body).toContain("/help");
|
||||
});
|
||||
|
||||
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 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const body = 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(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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// Signal tests cover event handler.silent ingest plugin behavior.
|
||||
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";
|
||||
|
||||
function requireInternalHookEventCall() {
|
||||
const [call] = internalHookMocks.createInternalHookEvent.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected internal hook event call");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
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).toHaveBeenCalledTimes(1);
|
||||
const [type, action, sessionKey, context] = requireInternalHookEventCall();
|
||||
expect(type).toBe("message");
|
||||
expect(action).toBe("received");
|
||||
expect(sessionKey).toContain("signal");
|
||||
expect(context).toEqual({
|
||||
from: "group:group-123",
|
||||
content: "hello without mention",
|
||||
timestamp: 1700000000000,
|
||||
channelId: "signal",
|
||||
accountId: "default",
|
||||
conversationId: "group:group-123",
|
||||
messageId: "1700000000000",
|
||||
metadata: {
|
||||
to: "group:group-123",
|
||||
provider: "signal",
|
||||
surface: "signal",
|
||||
threadId: undefined,
|
||||
senderId: "+15550001111",
|
||||
senderName: "Alice",
|
||||
senderUsername: undefined,
|
||||
senderE164: undefined,
|
||||
guildId: undefined,
|
||||
channelName: undefined,
|
||||
topicName: undefined,
|
||||
},
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
49
extensions/signal/src/monitor/event-handler.test-harness.ts
Normal file
49
extensions/signal/src/monitor/event-handler.test-harness.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Signal plugin module implements event handler harness behavior.
|
||||
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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
1184
extensions/signal/src/monitor/event-handler.ts
Normal file
1184
extensions/signal/src/monitor/event-handler.ts
Normal file
File diff suppressed because it is too large
Load Diff
137
extensions/signal/src/monitor/event-handler.types.ts
Normal file
137
extensions/signal/src/monitor/event-handler.types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// Signal type declarations define plugin contracts.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type {
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
SignalReactionNotificationMode,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
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: {
|
||||
cfg: OpenClawConfig;
|
||||
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;
|
||||
};
|
||||
68
extensions/signal/src/monitor/inbound-context.ts
Normal file
68
extensions/signal/src/monitor/inbound-context.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Signal plugin module implements inbound context behavior.
|
||||
import { filterChannelInboundQuoteContext } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
|
||||
import type { ContextVisibilityDecision } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
formatSignalSenderDisplay,
|
||||
isSignalSenderAllowed,
|
||||
resolveSignalSender,
|
||||
} from "../identity.js";
|
||||
import type { SignalDataMessage } from "./event-handler.types.js";
|
||||
|
||||
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 visibleQuote = filterChannelInboundQuoteContext(contextVisibilityMode, {
|
||||
body: quoteText,
|
||||
sender: quoteSender ? formatSignalSenderDisplay(quoteSender) : undefined,
|
||||
senderAllowed: quoteSenderAllowed,
|
||||
isQuote: true,
|
||||
});
|
||||
const decision: ContextVisibilityDecision = {
|
||||
include: Boolean(visibleQuote),
|
||||
reason: visibleQuote
|
||||
? contextVisibilityMode === "all"
|
||||
? "mode_all"
|
||||
: quoteSenderAllowed
|
||||
? "sender_allowed"
|
||||
: "quote_override"
|
||||
: "blocked",
|
||||
};
|
||||
|
||||
return {
|
||||
contextVisibilityMode,
|
||||
decision,
|
||||
quoteSenderAllowed,
|
||||
visibleQuoteText: visibleQuote?.body ?? "",
|
||||
visibleQuoteSender: visibleQuote?.sender,
|
||||
};
|
||||
}
|
||||
57
extensions/signal/src/monitor/mentions.ts
Normal file
57
extensions/signal/src/monitor/mentions.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Signal plugin module implements mentions behavior.
|
||||
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;
|
||||
}
|
||||
51
extensions/signal/src/normalize.test.ts
Normal file
51
extensions/signal/src/normalize.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Signal tests cover normalize plugin behavior.
|
||||
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);
|
||||
});
|
||||
});
|
||||
73
extensions/signal/src/normalize.ts
Normal file
73
extensions/signal/src/normalize.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Signal helper module supports normalize behavior.
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/string-coerce-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 = normalizeStringEntries([raw, normalized ?? ""]);
|
||||
|
||||
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;
|
||||
}
|
||||
55
extensions/signal/src/outbound-session.ts
Normal file
55
extensions/signal/src/outbound-session.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Signal plugin module implements outbound session behavior.
|
||||
import type { RoutePeer } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-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}`,
|
||||
};
|
||||
}
|
||||
19
extensions/signal/src/outbound-tool-trace-sanitize.test.ts
Normal file
19
extensions/signal/src/outbound-tool-trace-sanitize.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Signal outbound must strip assistant internal tool-trace scaffolding, matching
|
||||
// the sibling channel fixes tracked under #90684 (Telegram #95774 / Google Chat
|
||||
// #95084 / IRC #97214). Signal is plaintext-only, so leaked traces are verbatim.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { signalPlugin } from "./channel.js";
|
||||
|
||||
describe("signal outbound sanitizeText", () => {
|
||||
it("strips internal tool-trace banners before outbound delivery", () => {
|
||||
const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed";
|
||||
|
||||
expect(signalPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe("Done.");
|
||||
});
|
||||
|
||||
it("preserves ordinary assistant prose while sanitizing", () => {
|
||||
const text = "The pipeline has 3 open deals.";
|
||||
|
||||
expect(signalPlugin.outbound?.sanitizeText?.({ text, payload: { text } })).toBe(text);
|
||||
});
|
||||
});
|
||||
10
extensions/signal/src/probe.contract.test.ts
Normal file
10
extensions/signal/src/probe.contract.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Signal tests cover probe.contract plugin behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import type { SignalProbe } from "./probe.js";
|
||||
|
||||
describe("Signal probe contract", () => {
|
||||
it("keeps public probe aligned with base contract", () => {
|
||||
expectTypeOf<SignalProbe>().toMatchTypeOf<BaseProbeResult>();
|
||||
});
|
||||
});
|
||||
64
extensions/signal/src/probe.ts
Normal file
64
extensions/signal/src/probe.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Signal plugin module implements probe behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { type SignalApiMode, signalCheck, signalRpcRequest } from "./client-adapter.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,
|
||||
options: { apiMode?: SignalApiMode } = {},
|
||||
): Promise<SignalProbe> {
|
||||
const started = Date.now();
|
||||
const result: SignalProbe = {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: null,
|
||||
elapsedMs: 0,
|
||||
version: null,
|
||||
};
|
||||
const apiMode = options.apiMode ?? "native";
|
||||
const check = await signalCheck(baseUrl, timeoutMs, { apiMode });
|
||||
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,
|
||||
apiMode,
|
||||
});
|
||||
result.version = parseSignalVersion(version);
|
||||
} catch (err) {
|
||||
result.error = formatErrorMessage(err);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
ok: true,
|
||||
status: check.status ?? null,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
35
extensions/signal/src/reaction-level.ts
Normal file
35
extensions/signal/src/reaction-level.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Signal plugin module implements reaction level behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
resolveReactionLevel,
|
||||
type ReactionLevel,
|
||||
type ResolvedReactionLevel,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
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",
|
||||
});
|
||||
}
|
||||
23
extensions/signal/src/rpc-context.ts
Normal file
23
extensions/signal/src/rpc-context.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Signal plugin module implements rpc context behavior.
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-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 };
|
||||
}
|
||||
52
extensions/signal/src/runtime-api.ts
Normal file
52
extensions/signal/src/runtime-api.ts
Normal 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-contracts";
|
||||
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/runtime-group-policy";
|
||||
export {
|
||||
buildBaseAccountStatusSnapshot,
|
||||
buildBaseChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
export { normalizeE164 } from "openclaw/plugin-sdk/text-utility-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";
|
||||
14
extensions/signal/src/runtime.ts
Normal file
14
extensions/signal/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Signal plugin module implements runtime behavior.
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const {
|
||||
setRuntime: setSignalRuntime,
|
||||
getRuntime: getSignalRuntime,
|
||||
tryGetRuntime: getOptionalSignalRuntime,
|
||||
clearRuntime: clearSignalRuntime,
|
||||
} = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "signal",
|
||||
errorMessage: "Signal runtime not initialized",
|
||||
});
|
||||
export { clearSignalRuntime, getOptionalSignalRuntime, getSignalRuntime, setSignalRuntime };
|
||||
113
extensions/signal/src/send-reactions.test.ts
Normal file
113
extensions/signal/src/send-reactions.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Signal tests cover send reactions plugin behavior.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const rpcMock = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-config-runtime")>(
|
||||
"openclaw/plugin-sdk/plugin-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-adapter.js", () => ({
|
||||
signalRpcRequest: (...args: unknown[]) => rpcMock(...args),
|
||||
}));
|
||||
|
||||
let sendReactionSignal: typeof import("./send-reactions.js").sendReactionSignal;
|
||||
let removeReactionSignal: typeof import("./send-reactions.js").removeReactionSignal;
|
||||
|
||||
const SIGNAL_TEST_CFG = {
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function requireRpcParams(): Record<string, unknown> {
|
||||
const [call] = rpcMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Signal RPC call");
|
||||
}
|
||||
const [, params] = call;
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
throw new Error("expected Signal RPC params");
|
||||
}
|
||||
return params as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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, "🔥", {
|
||||
cfg: SIGNAL_TEST_CFG,
|
||||
});
|
||||
|
||||
expect(rpcMock).toHaveBeenCalledWith(
|
||||
"sendReaction",
|
||||
{
|
||||
emoji: "🔥",
|
||||
targetTimestamp: 123,
|
||||
targetAuthor: "123e4567-e89b-12d3-a456-426614174000",
|
||||
recipients: ["123e4567-e89b-12d3-a456-426614174000"],
|
||||
account: "+15550001111",
|
||||
},
|
||||
{
|
||||
baseUrl: "http://signal.local",
|
||||
timeoutMs: undefined,
|
||||
apiMode: undefined,
|
||||
},
|
||||
);
|
||||
const params = requireRpcParams();
|
||||
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, "✅", {
|
||||
cfg: SIGNAL_TEST_CFG,
|
||||
groupId: "group-id",
|
||||
targetAuthorUuid: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
|
||||
const params = requireRpcParams();
|
||||
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, "❌", { cfg: SIGNAL_TEST_CFG });
|
||||
|
||||
const params = requireRpcParams();
|
||||
expect(params.recipients).toEqual(["+15551230000"]);
|
||||
expect(params.targetAuthor).toBe("+15551230000");
|
||||
expect(params.remove).toBe(true);
|
||||
});
|
||||
});
|
||||
193
extensions/signal/src/send-reactions.ts
Normal file
193
extensions/signal/src/send-reactions.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Signal reactions via signal-cli JSON-RPC API
|
||||
*/
|
||||
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client-adapter.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
|
||||
export type SignalReactionOpts = {
|
||||
cfg: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
targetAuthor?: string;
|
||||
targetAuthorUuid?: string;
|
||||
groupId?: string;
|
||||
};
|
||||
|
||||
export type SignalReactionResult = {
|
||||
ok: boolean;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
type SignalReactionErrorMessages = {
|
||||
missingRecipient: string;
|
||||
invalidTargetTimestamp: string;
|
||||
missingEmoji: string;
|
||||
missingTargetAuthor: string;
|
||||
};
|
||||
|
||||
function normalizeSignalId(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return trimmed.replace(/^signal:/i, "").trim();
|
||||
}
|
||||
|
||||
function normalizeSignalUuid(raw: string): string {
|
||||
const trimmed = normalizeSignalId(raw);
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (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 = requireRuntimeConfig(params.opts.cfg, "Signal reactions");
|
||||
const apiMode = cfg.channels?.signal?.apiMode;
|
||||
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,
|
||||
apiMode,
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
2
extensions/signal/src/send.runtime.ts
Normal file
2
extensions/signal/src/send.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Signal plugin module implements send behavior.
|
||||
export { sendMessageSignal, sendTypingSignal } from "./send.js";
|
||||
201
extensions/signal/src/send.test.ts
Normal file
201
extensions/signal/src/send.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// Signal tests cover send plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const signalRpcRequestMock = vi.hoisted(() => vi.fn());
|
||||
const resolveOutboundAttachmentFromUrlMock = vi.hoisted(() =>
|
||||
vi.fn(async (_params: unknown) => ({ path: "/tmp/image.png", contentType: "image/png" })),
|
||||
);
|
||||
|
||||
vi.mock("./client-adapter.js", () => ({
|
||||
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
|
||||
"openclaw/plugin-sdk/media-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveOutboundAttachmentFromUrl: (params: unknown) =>
|
||||
resolveOutboundAttachmentFromUrlMock(params),
|
||||
};
|
||||
});
|
||||
|
||||
const { sendMessageSignal } = await import("./send.js");
|
||||
|
||||
const SIGNAL_TEST_CFG = {
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
default: {
|
||||
httpUrl: "http://signal.test",
|
||||
account: "+15550001111",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("sendMessageSignal receipts", () => {
|
||||
beforeEach(() => {
|
||||
signalRpcRequestMock.mockReset();
|
||||
resolveOutboundAttachmentFromUrlMock.mockClear();
|
||||
});
|
||||
|
||||
it("attaches a text receipt for timestamp results", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567890 });
|
||||
|
||||
const result = await sendMessageSignal("+15551234567", "hello", {
|
||||
cfg: SIGNAL_TEST_CFG,
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("1234567890");
|
||||
expect(result.timestamp).toBe(1234567890);
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("1234567890");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["1234567890"]);
|
||||
expect(result.receipt.raw).toEqual([
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1234567890",
|
||||
toJid: "+15551234567",
|
||||
timestamp: 1234567890,
|
||||
meta: { targetType: "recipient" },
|
||||
},
|
||||
]);
|
||||
expect(result.receipt.parts).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
platformMessageId: "1234567890",
|
||||
kind: "text",
|
||||
raw: {
|
||||
channel: "signal",
|
||||
messageId: "1234567890",
|
||||
toJid: "+15551234567",
|
||||
timestamp: 1234567890,
|
||||
meta: { targetType: "recipient" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.receipt.sentAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("attaches a media receipt for attachment sends", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567891 });
|
||||
|
||||
const result = await sendMessageSignal("group:group-1", "", {
|
||||
cfg: SIGNAL_TEST_CFG,
|
||||
mediaUrl: "/tmp/image.png",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
});
|
||||
|
||||
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalled();
|
||||
expect(result.messageId).toBe("1234567891");
|
||||
expect(result.timestamp).toBe(1234567891);
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("1234567891");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["1234567891"]);
|
||||
expect(result.receipt.raw).toEqual([
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1234567891",
|
||||
chatId: "group-1",
|
||||
timestamp: 1234567891,
|
||||
meta: { targetType: "group" },
|
||||
},
|
||||
]);
|
||||
expect(result.receipt.parts).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
platformMessageId: "1234567891",
|
||||
kind: "media",
|
||||
raw: {
|
||||
channel: "signal",
|
||||
messageId: "1234567891",
|
||||
chatId: "group-1",
|
||||
timestamp: 1234567891,
|
||||
meta: { targetType: "group" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.receipt.sentAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not invent platform ids when signal-cli omits a timestamp", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({});
|
||||
|
||||
const result = await sendMessageSignal("+15551234567", "hello", {
|
||||
cfg: SIGNAL_TEST_CFG,
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("unknown");
|
||||
expect(result.receipt.platformMessageIds).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not add approval reactions to ordinary outbound approval-looking text", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567892 });
|
||||
const text = [
|
||||
"Here is the command you asked about:",
|
||||
"/approve exec-live-approval allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, {
|
||||
cfg: {
|
||||
...SIGNAL_TEST_CFG,
|
||||
channels: {
|
||||
signal: {
|
||||
...SIGNAL_TEST_CFG.channels.signal,
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({ message: text }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add approval reactions to ordinary outbound text quoting a full prompt", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567893 });
|
||||
const text = [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-live-approval",
|
||||
"",
|
||||
"Reply with: /approve exec-live-approval allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, {
|
||||
cfg: {
|
||||
...SIGNAL_TEST_CFG,
|
||||
channels: {
|
||||
signal: {
|
||||
...SIGNAL_TEST_CFG.channels.signal,
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({ message: text }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
337
extensions/signal/src/send.ts
Normal file
337
extensions/signal/src/send.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
// Signal plugin module implements send behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
type MessageReceiptPartKind,
|
||||
type MessageReceiptSourceResult,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client-adapter.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;
|
||||
receipt: MessageReceipt;
|
||||
};
|
||||
|
||||
export type SignalRpcOpts = Pick<
|
||||
SignalSendOpts,
|
||||
"cfg" | "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: SignalRpcOpts) {
|
||||
if (opts.baseUrl?.trim() && opts.account?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
if (!opts.cfg) {
|
||||
throw new Error(
|
||||
"Signal RPC account resolution requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Signal RPC account resolution");
|
||||
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;
|
||||
}
|
||||
|
||||
function createSignalSendReceipt(params: {
|
||||
messageId: string;
|
||||
timestamp?: number;
|
||||
target: SignalTarget;
|
||||
kind: MessageReceiptPartKind;
|
||||
}): MessageReceipt {
|
||||
const messageId = params.messageId.trim();
|
||||
const results: MessageReceiptSourceResult[] =
|
||||
messageId && messageId !== "unknown"
|
||||
? [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId,
|
||||
meta: {
|
||||
targetType: params.target.type,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
if (results[0]) {
|
||||
if (params.timestamp != null) {
|
||||
results[0].timestamp = params.timestamp;
|
||||
}
|
||||
if (params.target.type === "group") {
|
||||
results[0].chatId = params.target.groupId;
|
||||
} else if (params.target.type === "recipient") {
|
||||
results[0].toJid = params.target.recipient;
|
||||
} else {
|
||||
results[0].toJid = params.target.username;
|
||||
}
|
||||
}
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results,
|
||||
kind: params.kind,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMessageSignal(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: SignalSendOpts,
|
||||
): Promise<SignalSendResult> {
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Signal send");
|
||||
const apiMode = cfg.channels?.signal?.apiMode;
|
||||
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,
|
||||
apiMode,
|
||||
});
|
||||
const timestamp = result?.timestamp;
|
||||
const messageId = timestamp ? String(timestamp) : "unknown";
|
||||
return {
|
||||
messageId,
|
||||
timestamp,
|
||||
receipt: createSignalSendReceipt({
|
||||
messageId,
|
||||
target,
|
||||
kind: attachments && attachments.length > 0 ? "media" : "text",
|
||||
...(timestamp != null ? { timestamp } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendTypingSignal(
|
||||
to: string,
|
||||
opts: SignalRpcOpts & { stop?: boolean },
|
||||
): Promise<boolean> {
|
||||
const accountInfo = await resolveSignalRpcAccountInfo(opts);
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Signal typing");
|
||||
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,
|
||||
apiMode: cfg.channels?.signal?.apiMode,
|
||||
});
|
||||
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 cfg = requireRuntimeConfig(opts.cfg, "Signal read receipt");
|
||||
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,
|
||||
apiMode: cfg.channels?.signal?.apiMode,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
267
extensions/signal/src/setup-core.ts
Normal file
267
extensions/signal/src/setup-core.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
// Signal plugin module implements setup core behavior.
|
||||
import {
|
||||
createCliPathTextInput,
|
||||
createDelegatedSetupWizardProxy,
|
||||
createDelegatedTextInputShouldPrompt,
|
||||
createPatchedAccountSetupAdapter,
|
||||
createSetupInputPresenceValidator,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
mergeAllowFromEntries,
|
||||
parseSetupEntriesAllowingWildcard,
|
||||
patchChannelConfigForAccount,
|
||||
promptParsedAllowFromForAccount,
|
||||
setAccountAllowFromForChannel,
|
||||
setSetupChannelEnabled,
|
||||
type ChannelSetupAdapter,
|
||||
type ChannelSetupWizard,
|
||||
type ChannelSetupWizardTextInput,
|
||||
type OpenClawConfig,
|
||||
createSetupTranslator,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveDefaultSignalAccountId, resolveSignalAccount } from "./accounts.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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: t("wizard.signal.allowlistTitle"),
|
||||
noteLines: [
|
||||
t("wizard.signal.allowlistIntro"),
|
||||
t("wizard.signal.examples"),
|
||||
"- +15555550123",
|
||||
"- uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
t("wizard.signal.multipleEntries"),
|
||||
`Docs: ${formatDocsLink("/signal", "signal")}`,
|
||||
],
|
||||
message: t("wizard.signal.allowFromPrompt"),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export const signalNumberTextInput: ChannelSetupWizardTextInput = {
|
||||
inputKey: "signalNumber",
|
||||
message: t("wizard.signal.botNumberPrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ??
|
||||
undefined,
|
||||
keepPrompt: (value) => t("wizard.signal.accountKeep", { value }),
|
||||
validate: ({ value }) =>
|
||||
normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR,
|
||||
normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value,
|
||||
};
|
||||
|
||||
export const signalCompletionNote = {
|
||||
title: t("wizard.signal.nextStepsTitle"),
|
||||
lines: [
|
||||
t("wizard.signal.nextLinkDevice"),
|
||||
t("wizard.signal.nextScanQr"),
|
||||
`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: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsSetup"),
|
||||
configuredHint: t("wizard.channels.statusSignalCliFound"),
|
||||
unconfiguredHint: t("wizard.channels.statusSignalCliMissing"),
|
||||
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),
|
||||
});
|
||||
}
|
||||
87
extensions/signal/src/setup-surface.ts
Normal file
87
extensions/signal/src/setup-surface.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Signal plugin module implements setup surface behavior.
|
||||
import {
|
||||
createSetupTranslator,
|
||||
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,
|
||||
signalCompletionNote,
|
||||
signalDmPolicy,
|
||||
signalNumberTextInput,
|
||||
} from "./setup-core.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "signal" as const;
|
||||
export const signalSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: createDetectedBinaryStatus({
|
||||
channelLabel: "Signal",
|
||||
binaryLabel: "signal-cli",
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsSetup"),
|
||||
configuredHint: t("wizard.channels.statusSignalCliFound"),
|
||||
unconfiguredHint: t("wizard.channels.statusSignalCliMissing"),
|
||||
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 ? t("wizard.signal.reinstallPrompt") : t("wizard.signal.installPrompt"),
|
||||
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),
|
||||
};
|
||||
148
extensions/signal/src/shared.ts
Normal file
148
extensions/signal/src/shared.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// Signal plugin module implements shared behavior.
|
||||
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 { normalizeStringifiedEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
type ResolvedSignalAccount,
|
||||
} from "./accounts.js";
|
||||
import { resolveSignalTarget } from "./aliases.js";
|
||||
import { SignalChannelConfigSchema } from "./config-schema.js";
|
||||
import { createSignalSetupWizardProxy } from "./setup-core.js";
|
||||
|
||||
const SIGNAL_CHANNEL = "signal" as const;
|
||||
|
||||
async function loadSignalChannelRuntime() {
|
||||
return await import("./channel.runtime.js");
|
||||
}
|
||||
|
||||
export const signalSetupWizard = createSignalSetupWizardProxy(
|
||||
async () => (await loadSignalChannelRuntime()).signalSetupWizard,
|
||||
);
|
||||
|
||||
const signalConfigAdapterBase = createScopedChannelConfigAdapter<ResolvedSignalAccount>({
|
||||
sectionKey: SIGNAL_CHANNEL,
|
||||
listAccountIds: (cfg) => listSignalAccountIds(cfg),
|
||||
resolveAccount: adaptScopedAccountAccessor((params) => resolveSignalAccount(params)),
|
||||
defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg),
|
||||
clearBaseFields: ["account", "configPath", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
|
||||
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
normalizeStringifiedEntries(allowFrom)
|
||||
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
|
||||
.filter(Boolean),
|
||||
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
export const signalConfigAdapter = {
|
||||
...signalConfigAdapterBase,
|
||||
resolveDefaultTo({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}) {
|
||||
const raw = resolveSignalAccount({ cfg, accountId }).config.defaultTo;
|
||||
if (typeof raw !== "string" || !raw.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return resolveSignalTarget({ cfg, accountId, input: raw })?.to ?? raw.trim();
|
||||
} catch {
|
||||
return raw.trim();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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"
|
||||
>;
|
||||
}
|
||||
98
extensions/signal/src/sse-reconnect.ts
Normal file
98
extensions/signal/src/sse-reconnect.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Signal plugin module implements sse reconnect behavior.
|
||||
import {
|
||||
computeBackoff,
|
||||
logVerbose,
|
||||
shouldLogVerbose,
|
||||
sleepWithAbort,
|
||||
type BackoffPolicy,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import { type SignalApiMode, type SignalSseEvent, streamSignalEvents } from "./client-adapter.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;
|
||||
timeoutMs?: number;
|
||||
apiMode?: SignalApiMode;
|
||||
policy?: Partial<BackoffPolicy>;
|
||||
};
|
||||
|
||||
export async function runSignalSseLoop({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal,
|
||||
runtime,
|
||||
onEvent,
|
||||
timeoutMs,
|
||||
apiMode,
|
||||
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,
|
||||
timeoutMs,
|
||||
apiMode,
|
||||
onEvent: (event: SignalSseEvent) => {
|
||||
reconnectAttempts = 0;
|
||||
onEvent(event);
|
||||
},
|
||||
logger: {
|
||||
log: runtime.log,
|
||||
error: runtime.error,
|
||||
},
|
||||
});
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
reconnectAttempts += 1;
|
||||
const delayMs = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||
logReconnectVerbose(`Signal stream ended, reconnecting in ${delayMs / 1000}s...`);
|
||||
await sleepWithAbort(delayMs, abortSignal);
|
||||
} catch (err) {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
runtime.error?.(`Signal stream error: ${String(err)}`);
|
||||
reconnectAttempts += 1;
|
||||
const delayMs = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||
runtime.log?.(`Signal connection lost, reconnecting in ${delayMs / 1000}s...`);
|
||||
try {
|
||||
await sleepWithAbort(delayMs, abortSignal);
|
||||
} catch (sleepErr) {
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
throw sleepErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
extensions/signal/src/uuid.ts
Normal file
14
extensions/signal/src/uuid.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Signal plugin module implements uuid behavior.
|
||||
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);
|
||||
}
|
||||
16
extensions/signal/tsconfig.json
Normal file
16
extensions/signal/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user