mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-05 06:17:34 +00:00
packages: openclaw channel
This commit is contained in:
1 parent
7c2c7503f2
commit
449e046e6f
9 files changed
+642
No files matched your search
@@ -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
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user