packages: openclaw channel

This commit is contained in:
Evgeny @ SimpleX Chat committed 2026-10-09 22:17:47 +00:00
1 parent 7c2c7503f2
commit 449e046e6f
9 files changed
+642

No files matched your search

+16
View File
@@ -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
+52
View File
@@ -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 `<state dir>/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 <CODE>`.
- Outbound targets are SimpleX contact ids: `simplex:<contact id>` or `<contact id>`.
## 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=<the OpenClaw image you run> 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 <CODE>
```
## 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 * * *" "<prompt>" --name "Digest" --announce --channel simplex --to <contact id>
```
+11
View File
@@ -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,
});
@@ -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" }
}
}
}
}
@@ -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"
}
}
}
@@ -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<string | number>;
};
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<string, SimplexAccountConfig | undefined> | 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;
}
@@ -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 = "<contact id|simplex:contact id>";
const resolveSimplexDmPolicy = createScopedDmSecurityResolver<ResolvedSimplexAccount>({
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<ResolvedSimplexAccount>({
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<ResolvedSimplexAccount> = 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<ResolvedSimplexAccount>({
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,
});
@@ -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<ChannelPlugin<ResolvedSimplexAccount>["gateway"]>["startAccount"]
>;
type SimplexOutboundAdapter = Pick<
ChannelOutboundAdapter,
"chunker" | "deliveryCapabilities" | "deliveryMode" | "textChunkLimit" | "sendText"
> & {
sendText: NonNullable<ChannelOutboundAdapter["sendText"]>;
sanitizeText: NonNullable<ChannelOutboundAdapter["sanitizeText"]>;
};
const SIMPLEX_TEXT_LIMIT = 3000;
const activeClients = new Map<string, api.ChatApi>();
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),
});
},
};
@@ -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<PluginRuntime>({
pluginId: "simplex",
errorMessage: "SimpleX runtime not initialized",
});
export { getSimplexRuntime, setSimplexRuntime };