From 449e046e6f145eece38995f60caa8ee8d1598db3 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:17:47 +0000 Subject: [PATCH] packages: openclaw channel --- packages/simplex-chat-openclaw/Dockerfile | 16 + packages/simplex-chat-openclaw/README.md | 52 ++++ packages/simplex-chat-openclaw/index.ts | 11 + .../openclaw.plugin.json | 33 +++ packages/simplex-chat-openclaw/package.json | 44 +++ packages/simplex-chat-openclaw/src/account.ts | 43 +++ packages/simplex-chat-openclaw/src/channel.ts | 156 ++++++++++ packages/simplex-chat-openclaw/src/gateway.ts | 277 ++++++++++++++++++ packages/simplex-chat-openclaw/src/runtime.ts | 10 + 9 files changed, 642 insertions(+) create mode 100644 packages/simplex-chat-openclaw/Dockerfile create mode 100644 packages/simplex-chat-openclaw/README.md create mode 100644 packages/simplex-chat-openclaw/index.ts create mode 100644 packages/simplex-chat-openclaw/openclaw.plugin.json create mode 100644 packages/simplex-chat-openclaw/package.json create mode 100644 packages/simplex-chat-openclaw/src/account.ts create mode 100644 packages/simplex-chat-openclaw/src/channel.ts create mode 100644 packages/simplex-chat-openclaw/src/gateway.ts create mode 100644 packages/simplex-chat-openclaw/src/runtime.ts diff --git a/packages/simplex-chat-openclaw/Dockerfile b/packages/simplex-chat-openclaw/Dockerfile new file mode 100644 index 0000000000..7cfb191eb7 --- /dev/null +++ b/packages/simplex-chat-openclaw/Dockerfile @@ -0,0 +1,16 @@ +ARG OPENCLAW_IMAGE=ghcr.io/openclaw/openclaw:latest +FROM ${OPENCLAW_IMAGE} + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY --chown=node:node package.json openclaw.plugin.json index.ts /opt/simplex-chat-openclaw/ +COPY --chown=node:node src /opt/simplex-chat-openclaw/src + +USER node +WORKDIR /opt/simplex-chat-openclaw +RUN npm install --omit=dev --omit=peer --allow-scripts=simplex-chat --no-audit --no-fund + +WORKDIR /app diff --git a/packages/simplex-chat-openclaw/README.md b/packages/simplex-chat-openclaw/README.md new file mode 100644 index 0000000000..1acd3e590e --- /dev/null +++ b/packages/simplex-chat-openclaw/README.md @@ -0,0 +1,52 @@ +# SimpleX channel for OpenClaw + +Direct messages between you and an OpenClaw agent over SimpleX: end-to-end encrypted, with no phone number or user ID. + +Version 0.1: text in direct chats. Replies are sent as plain text, split at 3,000 characters. Files, groups, streaming and Markdown rendering are not included yet. + +## How it works + +- The plugin runs the SimpleX chat core in the gateway process through the `simplex-chat` Node library. +- The chat database is stored in `/simplex/` in the OpenClaw state volume. +- On start, the plugin creates a bot profile and a SimpleX address with automatic acceptance, and logs the address. +- A new contact's first message receives a pairing code; the operator approves it with `openclaw pairing approve simplex `. +- Outbound targets are SimpleX contact ids: `simplex:` or ``. + +## Install with Docker + +The library builds a native add-on, and OpenClaw installs plugin dependencies without running install scripts. The plugin is therefore built into an image derived from the OpenClaw image: + +``` +docker build -t openclaw-simplex --build-arg OPENCLAW_IMAGE= packages/simplex-chat-openclaw +``` + +Recreate the gateway container from `openclaw-simplex` in place of the OpenClaw image, keeping every other option. Then: + +``` +docker exec -it openclaw-gateway openclaw plugins install --link /opt/simplex-chat-openclaw --force --accept-capabilities +docker exec -it openclaw-gateway openclaw config set --batch-json '[{"path":"channels.simplex","value":{"enabled":true,"name":"OpenClaw"}}]' +docker logs openclaw-gateway 2>&1 | grep "SimpleX address" +``` + +Connect to the printed address in the SimpleX app and send a message. The reply contains a pairing code and your contact id: + +``` +docker exec -it openclaw-gateway openclaw pairing approve simplex +``` + +## Config + +`channels.simplex`: + +``` +enabled boolean, default true +name bot display name, default "OpenClaw" +dmPolicy pairing | allowlist | open | disabled, default pairing +allowFrom contact ids allowed to message the agent +``` + +## Scheduled delivery + +``` +openclaw cron create "0 7 * * *" "" --name "Digest" --announce --channel simplex --to +``` diff --git a/packages/simplex-chat-openclaw/index.ts b/packages/simplex-chat-openclaw/index.ts new file mode 100644 index 0000000000..f8e8c53a80 --- /dev/null +++ b/packages/simplex-chat-openclaw/index.ts @@ -0,0 +1,11 @@ +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; +import { simplexPlugin } from "./src/channel.js"; +import { setSimplexRuntime } from "./src/runtime.js"; + +export default defineChannelPluginEntry({ + id: "simplex", + name: "SimpleX", + description: "SimpleX Chat channel: end-to-end encrypted direct messages", + plugin: simplexPlugin, + setRuntime: setSimplexRuntime, +}); diff --git a/packages/simplex-chat-openclaw/openclaw.plugin.json b/packages/simplex-chat-openclaw/openclaw.plugin.json new file mode 100644 index 0000000000..5d2f9d6d4b --- /dev/null +++ b/packages/simplex-chat-openclaw/openclaw.plugin.json @@ -0,0 +1,33 @@ +{ + "id": "simplex", + "name": "SimpleX", + "description": "SimpleX Chat channel: end-to-end encrypted direct messages over the SimpleX network.", + "categories": ["channels"], + "channels": ["simplex"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "channelConfigs": { + "simplex": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "name": { "type": "string" }, + "dmPolicy": { "type": "string", "enum": ["pairing", "allowlist", "open", "disabled"] }, + "allowFrom": { + "type": "array", + "items": { "type": ["string", "number"] } + } + } + }, + "uiHints": { + "name": { "label": "Bot display name" }, + "allowFrom": { "label": "Allowed SimpleX contact ids" } + } + } + } +} diff --git a/packages/simplex-chat-openclaw/package.json b/packages/simplex-chat-openclaw/package.json new file mode 100644 index 0000000000..011aa93179 --- /dev/null +++ b/packages/simplex-chat-openclaw/package.json @@ -0,0 +1,44 @@ +{ + "name": "@simplex-chat/openclaw", + "version": "0.1.0", + "description": "SimpleX Chat channel for OpenClaw: direct messages over the SimpleX network", + "type": "module", + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/simplex-chat/simplex-chat.git", + "directory": "packages/simplex-chat-openclaw" + }, + "dependencies": { + "@simplex-chat/types": "^0.10.3", + "simplex-chat": "7.0.2" + }, + "peerDependencies": { + "openclaw": ">=2026.9.9" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "simplex", + "label": "SimpleX", + "selectionLabel": "SimpleX Chat", + "docsPath": "/channels/simplex", + "docsLabel": "simplex", + "blurb": "End-to-end encrypted direct messages without phone numbers or user IDs.", + "order": 60 + }, + "compat": { + "pluginApi": ">=2026.9.9" + }, + "build": { + "openclawVersion": "2026.9.9" + } + } +} diff --git a/packages/simplex-chat-openclaw/src/account.ts b/packages/simplex-chat-openclaw/src/account.ts new file mode 100644 index 0000000000..4832b6909a --- /dev/null +++ b/packages/simplex-chat-openclaw/src/account.ts @@ -0,0 +1,43 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; + +export const SIMPLEX_CHANNEL_ID = "simplex"; + +export type SimplexAccountConfig = { + enabled?: boolean; + name?: string; + dmPolicy?: string; + allowFrom?: Array; +}; + +export type ResolvedSimplexAccount = { + accountId: string; + name: string; + enabled: boolean; + configured: boolean; + config: SimplexAccountConfig; +}; + +export function readSimplexSection(cfg: OpenClawConfig): SimplexAccountConfig | undefined { + return (cfg.channels as Record | undefined)?.simplex; +} + +export function resolveSimplexAccount(cfg: OpenClawConfig): ResolvedSimplexAccount { + const config = readSimplexSection(cfg) ?? {}; + return { + accountId: DEFAULT_ACCOUNT_ID, + name: config.name ?? "OpenClaw", + enabled: config.enabled !== false, + configured: readSimplexSection(cfg) !== undefined, + config, + }; +} + +export function normalizeSimplexTarget(target: string): string { + return target.trim().replace(/^simplex:/i, ""); +} + +export function parseSimplexContactId(target: string): number | undefined { + const id = normalizeSimplexTarget(target); + return /^\d+$/.test(id) ? Number(id) : undefined; +} diff --git a/packages/simplex-chat-openclaw/src/channel.ts b/packages/simplex-chat-openclaw/src/channel.ts new file mode 100644 index 0000000000..4ca4f4135f --- /dev/null +++ b/packages/simplex-chat-openclaw/src/channel.ts @@ -0,0 +1,156 @@ +import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; +import { + createScopedDmSecurityResolver, + createTopLevelChannelConfigAdapter, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract"; +import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback"; +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound"; +import { + DEFAULT_ACCOUNT_ID, + formatPairingApproveHint, + type ChannelPlugin, +} from "openclaw/plugin-sdk/channel-plugin-common"; +import { + buildChannelOutboundSessionRoute, + type ChannelOutboundSessionRouteParams, +} from "openclaw/plugin-sdk/core"; +import { buildPassiveChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; +import { + collectStatusIssuesFromLastError, + createComputedAccountStatusAdapter, + createDefaultChannelRuntimeState, +} from "openclaw/plugin-sdk/status-helpers"; +import { + normalizeSimplexTarget, + parseSimplexContactId, + readSimplexSection, + resolveSimplexAccount, + SIMPLEX_CHANNEL_ID, + type ResolvedSimplexAccount, +} from "./account.js"; +import { + simplexOutboundAdapter, + simplexPairingTextAdapter, + startSimplexGatewayAccount, +} from "./gateway.js"; + +const SIMPLEX_TARGET_HINT = ""; + +const resolveSimplexDmPolicy = createScopedDmSecurityResolver({ + channelKey: SIMPLEX_CHANNEL_ID, + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + defaultPolicy: "pairing", + approveHint: formatPairingApproveHint(SIMPLEX_CHANNEL_ID), + normalizeEntry: normalizeSimplexTarget, +}); + +const simplexConfigAdapter = createTopLevelChannelConfigAdapter({ + sectionKey: SIMPLEX_CHANNEL_ID, + resolveAccount: resolveSimplexAccount, + listAccountIds: (cfg) => (readSimplexSection(cfg) ? [DEFAULT_ACCOUNT_ID] : []), + deleteMode: "remove-section", + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => allowFrom.map((entry) => normalizeSimplexTarget(String(entry))), +}); + +function resolveSimplexOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { + const contactId = parseSimplexContactId(params.target); + if (contactId === undefined) { + return null; + } + return buildChannelOutboundSessionRoute({ + cfg: params.cfg, + agentId: params.agentId, + channel: SIMPLEX_CHANNEL_ID, + accountId: params.accountId, + recipientSessionExact: true, + peer: { kind: "direct", id: String(contactId) }, + chatType: "direct", + from: `simplex:${contactId}`, + to: `simplex:${contactId}`, + }); +} + +const simplexPluginOutboundAdapter: ChannelOutboundAdapter = { + ...simplexOutboundAdapter, + resolveTarget: ({ to }) => { + const contactId = parseSimplexContactId(to ?? ""); + return contactId === undefined + ? { ok: false, error: missingTargetError("SimpleX", SIMPLEX_TARGET_HINT) } + : { ok: true, to: String(contactId) }; + }, +}; + +export const simplexPlugin: ChannelPlugin = createChatChannelPlugin({ + base: { + id: SIMPLEX_CHANNEL_ID, + meta: { + id: SIMPLEX_CHANNEL_ID, + label: "SimpleX", + selectionLabel: "SimpleX Chat", + docsPath: "/channels/simplex", + docsLabel: "simplex", + blurb: "End-to-end encrypted direct messages without phone numbers or user IDs.", + order: 60, + }, + capabilities: { + chatTypes: ["direct"], + media: false, + }, + reload: { configPrefixes: ["channels.simplex"] }, + config: { + ...simplexConfigAdapter, + isConfigured: (account) => account.configured, + describeAccount: (account) => + describeAccountSnapshot({ account, configured: account.configured }), + }, + setup: { + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + simplex: { ...readSimplexSection(cfg), enabled: true }, + }, + }), + }, + messaging: { + targetPrefixes: [SIMPLEX_CHANNEL_ID], + normalizeTarget: normalizeSimplexTarget, + inferTargetChatType: ({ to }) => (parseSimplexContactId(to) === undefined ? undefined : "direct"), + targetResolver: { + looksLikeId: (input) => parseSimplexContactId(input) !== undefined, + hint: SIMPLEX_TARGET_HINT, + }, + resolveOutboundSessionRoute: resolveSimplexOutboundSessionRoute, + }, + message: createChannelMessageAdapterFromOutbound({ + id: SIMPLEX_CHANNEL_ID, + outbound: simplexOutboundAdapter, + }), + status: createComputedAccountStatusAdapter({ + defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), + collectStatusIssues: (accounts) => collectStatusIssuesFromLastError(SIMPLEX_CHANNEL_ID, accounts), + buildChannelSummary: ({ snapshot }) => buildPassiveChannelStatusSummary(snapshot), + resolveAccountSnapshot: ({ account }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + }), + gateway: { + startAccount: startSimplexGatewayAccount, + }, + }, + pairing: { + text: simplexPairingTextAdapter, + }, + security: { + resolveDmPolicy: resolveSimplexDmPolicy, + }, + outbound: simplexPluginOutboundAdapter, +}); diff --git a/packages/simplex-chat-openclaw/src/gateway.ts b/packages/simplex-chat-openclaw/src/gateway.ts new file mode 100644 index 0000000000..1c24d8a580 --- /dev/null +++ b/packages/simplex-chat-openclaw/src/gateway.ts @@ -0,0 +1,277 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { T } from "@simplex-chat/types"; +import type { api } from "simplex-chat"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract"; +import type { + ChannelIngressContextBinding, + StableChannelIngressIdentityParams, +} from "openclaw/plugin-sdk/channel-ingress-runtime"; +import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-outbound"; +import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common"; +import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { channelReadyPatch } from "openclaw/plugin-sdk/gateway-runtime"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { + chunkTextForOutbound, + sanitizeAssistantVisibleText, + stripMarkdown, +} from "openclaw/plugin-sdk/text-chunking"; +import { + normalizeSimplexTarget, + parseSimplexContactId, + SIMPLEX_CHANNEL_ID, + type ResolvedSimplexAccount, +} from "./account.js"; +import { getSimplexRuntime } from "./runtime.js"; + +type SimplexGatewayStart = NonNullable< + NonNullable["gateway"]>["startAccount"] +>; +type SimplexOutboundAdapter = Pick< + ChannelOutboundAdapter, + "chunker" | "deliveryCapabilities" | "deliveryMode" | "textChunkLimit" | "sendText" +> & { + sendText: NonNullable; + sanitizeText: NonNullable; +}; + +const SIMPLEX_TEXT_LIMIT = 3000; +const activeClients = new Map(); + +const simplexIngressIdentity = { + key: "simplex-contact", + normalizeEntry: (entry: string) => { + const normalized = normalizeSimplexTarget(entry); + return normalized === "*" || /^\d+$/.test(normalized) ? normalized : null; + }, + normalizeSubject: (value: string) => (/^\d+$/.test(value) ? value : null), + sensitivity: "pii", + entryIdPrefix: "simplex-entry", +} satisfies StableChannelIngressIdentityParams; + +function renderPlainText(cfg: OpenClawConfig, accountId: string, text: string): string { + const runtime = getSimplexRuntime(); + const tableMode = runtime.channel.text.resolveMarkdownTableMode({ + cfg, + channel: SIMPLEX_CHANNEL_ID, + accountId, + }); + return stripMarkdown(runtime.channel.text.convertMarkdownTables(text, tableMode)); +} + +async function sendTextChunks(client: api.ChatApi, contactId: number, text: string) { + for (const chunk of chunkTextForOutbound(text, SIMPLEX_TEXT_LIMIT)) { + await client.apiSendTextMessage([T.ChatType.Direct, contactId], chunk); + } +} + +export const startSimplexGatewayAccount: SimplexGatewayStart = async (ctx) => { + const account = ctx.account; + ctx.setStatus({ accountId: account.accountId, lifecycle: "starting" }); + const channelRuntime = ctx.channelRuntime as PluginRuntime["channel"] | undefined; + if (!channelRuntime?.inbound?.buildContext) { + throw new Error("SimpleX requires its registered channel runtime context builder"); + } + const runtime = getSimplexRuntime(); + const pairing = createChannelPairingController({ + core: runtime, + channel: SIMPLEX_CHANNEL_ID, + accountId: account.accountId, + }); + + const resolveInboundAccess = async ( + contactId: string, + rawBody: string, + contextBinding?: ChannelIngressContextBinding, + ) => + await channelRuntime.inbound.ingress.resolveStable({ + channelId: SIMPLEX_CHANNEL_ID, + accountId: account.accountId, + identity: simplexIngressIdentity, + cfg: ctx.cfg, + useDefaultPairingStore: true, + subject: { stableId: contactId }, + conversation: { kind: "direct", id: contactId }, + contextBinding, + dmPolicy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom, + command: runtime.channel.commands.shouldComputeCommandAuthorized(rawBody, ctx.cfg) + ? { modeWhenAccessGroupsOff: "configured" } + : undefined, + }); + + const receiveMessage = async (client: api.ChatApi, chatItem: T.AChatItem, text: string) => { + if (chatItem.chatInfo.type !== "direct" || !text) { + return; + } + const contact = chatItem.chatInfo.contact; + const contactId = String(contact.contactId); + const reply = async (message: string) => { + await sendTextChunks(client, contact.contactId, message); + }; + const access = await resolveInboundAccess(contactId, text); + if (access.senderAccess.decision === "pairing") { + await pairing.issueChallenge({ + senderId: contactId, + senderIdLine: `Your SimpleX contact id: ${contactId}`, + sendPairingReply: reply, + onCreated: () => { + ctx.log?.info?.(`[${account.accountId}] SimpleX pairing request from contact ${contactId}`); + }, + onReplyError: (err) => { + ctx.log?.warn?.( + `[${account.accountId}] SimpleX pairing reply to contact ${contactId} failed: ${String(err)}`, + ); + }, + }); + return; + } + if (access.senderAccess.decision !== "allow") { + ctx.log?.debug?.( + `[${account.accountId}] blocked SimpleX contact ${contactId} (${access.senderAccess.reasonCode})`, + ); + return; + } + const { dispatchInboundDirectDm } = await import("openclaw/plugin-sdk/channel-inbound"); + await dispatchInboundDirectDm({ + channelRuntime, + resolveChannelIngress: async (contextBinding) => { + const exactAccess = await resolveInboundAccess(contactId, text, contextBinding); + if (!exactAccess.senderAccess.allowed) { + throw new Error(`SimpleX contact authorization changed before dispatch (${contactId})`); + } + return exactAccess; + }, + cfg: ctx.cfg, + channel: SIMPLEX_CHANNEL_ID, + channelLabel: "SimpleX", + accountId: account.accountId, + peer: { kind: "direct", id: contactId }, + senderId: contactId, + senderAddress: `simplex:${contactId}`, + recipientAddress: `simplex:${account.accountId}`, + conversationLabel: contact.profile.displayName, + rawBody: text, + messageId: String(chatItem.chatItem.meta.itemId), + timestamp: Date.parse(chatItem.chatItem.meta.itemTs), + commandAuthorized: access.commandAccess.requested + ? access.commandAccess.authorized + : undefined, + deliver: async (payload) => { + const message = renderPlainText( + ctx.cfg, + account.accountId, + sanitizeAssistantVisibleText(payload.text ?? ""), + ); + if (message) { + await reply(message); + } + }, + onRecordError: (err) => { + ctx.log?.error?.( + `[${account.accountId}] failed recording SimpleX inbound session: ${String(err)}`, + ); + }, + onDispatchError: (err, info) => { + ctx.log?.error?.(`[${account.accountId}] SimpleX ${info.kind} reply failed: ${String(err)}`); + }, + }); + }; + + await runPassiveAccountLifecycle({ + abortSignal: ctx.abortSignal, + start: async () => { + const { bot, util } = await import("simplex-chat"); + const dbDir = path.join(resolveStateDir(), "simplex"); + await mkdir(dbDir, { recursive: true }); + const started = bot.run({ + profile: { displayName: account.name, fullName: "" }, + dbOpts: { type: "sqlite", filePrefix: path.join(dbDir, account.accountId) }, + options: { logContacts: false }, + onMessage: (chatItem, content) => { + started + .then(([client]) => receiveMessage(client, chatItem, content.text)) + .catch((err) => { + ctx.log?.error?.( + `[${account.accountId}] SimpleX message handling failed: ${String(err)}`, + ); + }); + }, + }); + const [client, , address] = await started; + activeClients.set(account.accountId, client); + ctx.log?.info?.( + `[${account.accountId}] SimpleX address: ${ + address ? util.contactAddressStr(address.connLinkContact) : "none" + }`, + ); + ctx.setStatus(channelReadyPatch({ accountId: account.accountId })); + return { + stop: async () => { + activeClients.delete(account.accountId); + await client.close(); + ctx.log?.info?.(`[${account.accountId}] SimpleX client stopped`); + }, + }; + }, + stop: async (handle) => { + await handle.stop(); + }, + }); +}; + +export const simplexPairingTextAdapter = { + idLabel: "simplexContactId", + message: "Your pairing request has been approved!", + normalizeAllowEntry: normalizeSimplexTarget, + notify: async ({ id, message, accountId }: { id: string; message: string; accountId?: string }) => { + const client = activeClients.get(accountId ?? DEFAULT_ACCOUNT_ID); + const contactId = parseSimplexContactId(id); + if (client && contactId !== undefined) { + await sendTextChunks(client, contactId, message); + } + }, +}; + +export const simplexOutboundAdapter: SimplexOutboundAdapter = { + deliveryMode: "direct", + textChunkLimit: SIMPLEX_TEXT_LIMIT, + chunker: chunkTextForOutbound, + sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text), + deliveryCapabilities: { + durableFinal: { + text: true, + messageSendingHooks: true, + }, + }, + sendText: async ({ cfg, to, text, accountId, assertDirectAdapterHandoff, onPlatformSendDispatch }) => { + const aid = accountId ?? DEFAULT_ACCOUNT_ID; + const client = activeClients.get(aid); + if (!client) { + throw new Error(`SimpleX client not running for account ${aid}`); + } + const contactId = parseSimplexContactId(to); + if (contactId === undefined) { + throw new Error(`SimpleX target must be a contact id, got ${to}`); + } + const message = renderPlainText(cfg, aid, text ?? ""); + if (!message) { + throw new Error("SimpleX send requires non-empty text after markdown stripping."); + } + assertDirectAdapterHandoff?.(); + if (onPlatformSendDispatch) { + await onPlatformSendDispatch(); + assertDirectAdapterHandoff?.(); + } + const [sent] = await client.apiSendTextMessage([T.ChatType.Direct, contactId], message); + return attachChannelToResult(SIMPLEX_CHANNEL_ID, { + to: String(contactId), + messageId: String(sent.chatItem.meta.itemId), + }); + }, +}; diff --git a/packages/simplex-chat-openclaw/src/runtime.ts b/packages/simplex-chat-openclaw/src/runtime.ts new file mode 100644 index 0000000000..9c47b5667d --- /dev/null +++ b/packages/simplex-chat-openclaw/src/runtime.ts @@ -0,0 +1,10 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; + +const { setRuntime: setSimplexRuntime, getRuntime: getSimplexRuntime } = + createPluginRuntimeStore({ + pluginId: "simplex", + errorMessage: "SimpleX runtime not initialized", + }); + +export { getSimplexRuntime, setSimplexRuntime };