@open-ic/openchat_openclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/index.ts +73 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +24 -0
- package/scripts/generate-key.ts +54 -0
- package/src/bot-definition.ts +57 -0
- package/src/channel.ts +112 -0
- package/src/execute-command.ts +175 -0
- package/src/factory.ts +40 -0
- package/src/onboarding.ts +59 -0
- package/src/runtime.ts +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# openchat-openclaw
|
|
2
|
+
|
|
3
|
+
An [OpenClaw](https://openclaw.ai) plugin that adds [OpenChat](https://oc.app) as a channel. Users interact with the AI agent by sending a `/prompt` command to a bot in OpenChat.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- OpenClaw gateway running and accessible from the internet (OpenChat needs to reach your `/bot_definition` and `/execute_command` endpoints)
|
|
8
|
+
- `openssl` on your PATH (for key generation)
|
|
9
|
+
- Node 22+
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
openclaw plugin install @open-ic/openchat_openclaw
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
### 1. Generate a bot identity
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx --package @open-ic/openchat_openclaw generate-key
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This generates a secp256k1 private key, writes it to `~/.openclaw/openchat-bot.pem` (mode 600), and prints the corresponding Internet Computer principal. Save the principal — you'll need it when registering the bot on OpenChat.
|
|
26
|
+
|
|
27
|
+
### 2. Supply required environment variables
|
|
28
|
+
|
|
29
|
+
The plugin requires two env vars that must be set before starting the gateway. Add them to `~/.openclaw/.env` (or export them in your shell):
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
# OpenChat's ES256 public key — used to verify JWTs on incoming bot commands.
|
|
33
|
+
# Obtain from: https://oc.app/api
|
|
34
|
+
OC_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
|
35
|
+
|
|
36
|
+
# User index canister ID — provided by OpenChat for your deployment.
|
|
37
|
+
OC_USER_INDEX_CANISTER="<canister-id>"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Variable | Required | Default | Description |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| `OC_PUBLIC_KEY` | Yes | — | OpenChat's ES256 public key for JWT verification |
|
|
43
|
+
| `OC_USER_INDEX_CANISTER` | Yes | — | OpenChat user index canister ID |
|
|
44
|
+
| `OC_PRIVATE_KEY` | See below | — | Bot private key PEM (alternative to config) |
|
|
45
|
+
| `OC_IC_HOST` | No | `https://icp-api.io` | Internet Computer host (override for local replicas) |
|
|
46
|
+
| `OC_STORAGE_INDEX_CANISTER` | No | `nbpzs-kqaaa-aaaar-qaaua-cai` | Storage index canister ID |
|
|
47
|
+
|
|
48
|
+
### 3. Configure the private key
|
|
49
|
+
|
|
50
|
+
The bot private key (from step 1) can be supplied in three ways, in priority order:
|
|
51
|
+
|
|
52
|
+
**Option A — Key file (recommended):**
|
|
53
|
+
|
|
54
|
+
The `generate-key` script writes the PEM to `~/.openclaw/openchat-bot.pem` and prints the exact command to run:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
openclaw config set channels.openchat.privateKeyFile ~/.openclaw/openchat-bot.pem
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Option B — Env var:**
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
# In ~/.openclaw/.env or your shell:
|
|
64
|
+
OC_PRIVATE_KEY="-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Option C — Inline config (not recommended for production):**
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
openclaw config set channels.openchat.privateKey "-----BEGIN EC PRIVATE KEY-----\n..."
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 4. Start the gateway
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
openclaw gateway run
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 5. Register the bot on OpenChat
|
|
80
|
+
|
|
81
|
+
Once the gateway is running and reachable from the internet, go to [oc.app](https://oc.app), navigate to **Settings → Bots → Register bot**, and provide:
|
|
82
|
+
|
|
83
|
+
- **Endpoint URL**: `https://<your-gateway-host>/openchat`
|
|
84
|
+
- **Principal**: the value printed in step 1
|
|
85
|
+
|
|
86
|
+
OpenChat will call `GET /openchat/bot_definition` to verify your endpoint is reachable before completing registration, and `POST /openchat/execute_command` each time a user sends `/prompt`.
|
package/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
|
+
import { handleBotDefinition } from "./src/bot-definition.js";
|
|
5
|
+
import { openChatPlugin, type ResolvedOpenChatAccount } from "./src/channel.js";
|
|
6
|
+
import { makeExecuteCommandHandler } from "./src/execute-command.js";
|
|
7
|
+
import { setOpenChatRuntime } from "./src/runtime.js";
|
|
8
|
+
|
|
9
|
+
// Active per-account context, keyed by accountId.
|
|
10
|
+
// Populated by gateway.startAccount, removed on abort.
|
|
11
|
+
const activeAccounts = new Map<string, { cfg: OpenClawConfig; account: ResolvedOpenChatAccount }>();
|
|
12
|
+
|
|
13
|
+
const plugin = {
|
|
14
|
+
id: "openchat",
|
|
15
|
+
name: "OpenChat",
|
|
16
|
+
description: "OpenChat channel plugin — AI agent via a /prompt bot command",
|
|
17
|
+
configSchema: emptyPluginConfigSchema(),
|
|
18
|
+
register(api: OpenClawPluginApi) {
|
|
19
|
+
setOpenChatRuntime(api.runtime);
|
|
20
|
+
|
|
21
|
+
// Register the channel, with a gateway that tracks active accounts.
|
|
22
|
+
api.registerChannel({
|
|
23
|
+
plugin: {
|
|
24
|
+
...openChatPlugin,
|
|
25
|
+
gateway: {
|
|
26
|
+
startAccount: async (ctx) => {
|
|
27
|
+
activeAccounts.set(ctx.accountId, {
|
|
28
|
+
cfg: ctx.cfg,
|
|
29
|
+
account: ctx.account as ResolvedOpenChatAccount,
|
|
30
|
+
});
|
|
31
|
+
ctx.abortSignal.addEventListener("abort", () => {
|
|
32
|
+
activeAccounts.delete(ctx.accountId);
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const executeCommandHandler = async (
|
|
40
|
+
req: import("node:http").IncomingMessage,
|
|
41
|
+
res: import("node:http").ServerResponse,
|
|
42
|
+
) => {
|
|
43
|
+
// Use the first active account (single-account use case).
|
|
44
|
+
// For multi-account support, the JWT scope could be used to pick the right one.
|
|
45
|
+
const entry = activeAccounts.values().next().value;
|
|
46
|
+
if (!entry) {
|
|
47
|
+
res.statusCode = 503;
|
|
48
|
+
res.end(JSON.stringify({ error: "OpenChat channel not started" }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const handler = makeExecuteCommandHandler(entry.cfg, entry.account);
|
|
52
|
+
await handler(req, res);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// GET /bot_definition and POST /execute_command at root — OpenChat bot registration
|
|
56
|
+
// only accepts an origin URL, so it expects these paths at the root.
|
|
57
|
+
// We also register the /openchat/* prefixed variants for clarity.
|
|
58
|
+
for (const prefix of ["", "/openchat"]) {
|
|
59
|
+
api.registerHttpRoute({
|
|
60
|
+
path: `${prefix}/bot_definition`,
|
|
61
|
+
handler: (req, res) => {
|
|
62
|
+
handleBotDefinition(req, res);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
api.registerHttpRoute({
|
|
66
|
+
path: `${prefix}/execute_command`,
|
|
67
|
+
handler: executeCommandHandler,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-ic/openchat_openclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw OpenChat channel plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"generate-key": "tsx scripts/generate-key.ts"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@open-ic/openchat-botclient-ts": "^1.0.64"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"openclaw": "latest",
|
|
14
|
+
"tsx": "latest"
|
|
15
|
+
},
|
|
16
|
+
"openclaw": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --import tsx
|
|
2
|
+
/**
|
|
3
|
+
* Generates a secp256k1 private key, writes it to ~/.openclaw/openchat-bot.pem,
|
|
4
|
+
* and prints the corresponding Internet Computer principal — everything needed
|
|
5
|
+
* to register an OpenChat bot before the gateway is running.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* npx --package @open-ic/openchat_openclaw generate-key
|
|
9
|
+
*
|
|
10
|
+
* Output:
|
|
11
|
+
* ~/.openclaw/openchat-bot.pem → configure with: openclaw config set channels.openchat.privateKeyFile ~/.openclaw/openchat-bot.pem
|
|
12
|
+
* Principal → use when registering the bot on OpenChat
|
|
13
|
+
*/
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
import { mkdirSync, writeFileSync, chmodSync, existsSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { BotClientFactory } from "@open-ic/openchat-botclient-ts";
|
|
19
|
+
|
|
20
|
+
// Generate a secp256k1 key in traditional EC format (BEGIN EC PRIVATE KEY),
|
|
21
|
+
// which is what the @dfinity/identity-secp256k1 SDK expects.
|
|
22
|
+
const pem = execSync(
|
|
23
|
+
"openssl ecparam -name secp256k1 -genkey -noout | openssl ec -outform PEM 2>/dev/null",
|
|
24
|
+
)
|
|
25
|
+
.toString()
|
|
26
|
+
.trim();
|
|
27
|
+
|
|
28
|
+
// Write the PEM to ~/.openclaw/openchat-bot.pem (mode 600 — private key).
|
|
29
|
+
const pemDir = join(homedir(), ".openclaw");
|
|
30
|
+
const pemPath = join(pemDir, "openchat-bot.pem");
|
|
31
|
+
mkdirSync(pemDir, { recursive: true });
|
|
32
|
+
if (existsSync(pemPath)) {
|
|
33
|
+
console.error(`Error: ${pemPath} already exists. Remove it first if you want to regenerate.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(pemPath, pem + "\n", { encoding: "utf8" });
|
|
37
|
+
chmodSync(pemPath, 0o600);
|
|
38
|
+
|
|
39
|
+
// BotClientFactory logs "Principal: <text>" to console as a side effect of createAgent.
|
|
40
|
+
// We use a dummy public key and canister ID since we only need the identity to be created.
|
|
41
|
+
console.log("=== OpenChat Bot Identity ===\n");
|
|
42
|
+
console.log("Deriving principal from key (you will see it printed below)...\n");
|
|
43
|
+
|
|
44
|
+
new BotClientFactory({
|
|
45
|
+
openchatPublicKey: "dummy",
|
|
46
|
+
icHost: "https://icp-api.io",
|
|
47
|
+
identityPrivateKey: pem,
|
|
48
|
+
openStorageCanisterId: "aaaaa-aa",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
console.log(`\nPrivate key saved to: ${pemPath}`);
|
|
52
|
+
console.log("\nConfigure OpenClaw to use it:");
|
|
53
|
+
console.log(` openclaw config set channels.openchat.privateKeyFile ${pemPath}`);
|
|
54
|
+
console.log("\nEndpoint to register with OpenChat: https://<your-gateway-host>/openchat");
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { Permissions } from "@open-ic/openchat-botclient-ts";
|
|
3
|
+
|
|
4
|
+
const emptyPermissions = { chat: [], community: [], message: [] };
|
|
5
|
+
|
|
6
|
+
const BOT_DEFINITION = {
|
|
7
|
+
description:
|
|
8
|
+
"An AI agent powered by OpenClaw. Send it a /prompt and it will reply using your configured AI model.",
|
|
9
|
+
// No autonomous_config — this bot only responds to explicit /prompt commands,
|
|
10
|
+
// so it doesn't need to subscribe to all chat messages.
|
|
11
|
+
commands: [
|
|
12
|
+
{
|
|
13
|
+
name: "prompt",
|
|
14
|
+
default_role: "Participant",
|
|
15
|
+
description: "Send a message to the AI agent",
|
|
16
|
+
// Allow use in direct messages (the primary use case)
|
|
17
|
+
direct_messages: true,
|
|
18
|
+
permissions: Permissions.encodePermissions({
|
|
19
|
+
...emptyPermissions,
|
|
20
|
+
message: ["Text"],
|
|
21
|
+
}),
|
|
22
|
+
params: [
|
|
23
|
+
{
|
|
24
|
+
name: "prompt",
|
|
25
|
+
required: true,
|
|
26
|
+
description: "Your message to the AI agent",
|
|
27
|
+
placeholder: "Ask me anything...",
|
|
28
|
+
param_type: {
|
|
29
|
+
StringParam: {
|
|
30
|
+
min_length: 1,
|
|
31
|
+
max_length: 5000,
|
|
32
|
+
choices: [],
|
|
33
|
+
multi_line: true,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function handleBotDefinition(req: IncomingMessage, res: ServerResponse): boolean {
|
|
43
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
44
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
45
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
46
|
+
|
|
47
|
+
if (req.method === "OPTIONS") {
|
|
48
|
+
res.statusCode = 204;
|
|
49
|
+
res.end();
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
res.setHeader("Content-Type", "application/json");
|
|
54
|
+
res.statusCode = 200;
|
|
55
|
+
res.end(JSON.stringify(BOT_DEFINITION));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { type ChannelPlugin, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
3
|
+
import { openChatOnboardingAdapter } from "./onboarding.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the bot private key PEM using this priority order:
|
|
7
|
+
* 1. OC_PRIVATE_KEY env var
|
|
8
|
+
* 2. privateKeyFile path in config (reads the file)
|
|
9
|
+
* 3. privateKey raw value in config (newlines may be escaped as \n)
|
|
10
|
+
*
|
|
11
|
+
* Returns undefined if none are set.
|
|
12
|
+
*/
|
|
13
|
+
export function resolvePrivateKey(config: OpenChatAccountConfig): string | undefined {
|
|
14
|
+
if (process.env.OC_PRIVATE_KEY) {
|
|
15
|
+
return process.env.OC_PRIVATE_KEY.replace(/\\n/g, "\n");
|
|
16
|
+
}
|
|
17
|
+
if (config.privateKeyFile) {
|
|
18
|
+
try {
|
|
19
|
+
return readFileSync(config.privateKeyFile, "utf8").trim();
|
|
20
|
+
} catch (err) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`[openchat] Could not read privateKeyFile "${config.privateKeyFile}": ${String(err)}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (config.privateKey) {
|
|
27
|
+
return config.privateKey.replace(/\\n/g, "\n");
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const meta = {
|
|
33
|
+
id: "openchat",
|
|
34
|
+
label: "OpenChat",
|
|
35
|
+
selectionLabel: "OpenChat (Bot)",
|
|
36
|
+
docsPath: "/channels/openchat",
|
|
37
|
+
blurb: "Talk to the AI agent directly inside OpenChat via a bot.",
|
|
38
|
+
systemImage: "bubble.left.and.bubble.right.fill",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface OpenChatAccountConfig {
|
|
42
|
+
/** Path to the PEM file on disk. Takes priority over privateKey. */
|
|
43
|
+
privateKeyFile?: string;
|
|
44
|
+
/** Raw PEM string (with \n escaped as \\n). Use privateKeyFile instead when possible. */
|
|
45
|
+
privateKey?: string;
|
|
46
|
+
enabled?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ResolvedOpenChatAccount {
|
|
50
|
+
accountId: string;
|
|
51
|
+
name: string;
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
config: OpenChatAccountConfig;
|
|
54
|
+
/** Resolved PEM: env var > key file > raw config value. */
|
|
55
|
+
privateKey?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const openChatPlugin: ChannelPlugin<ResolvedOpenChatAccount> = {
|
|
59
|
+
id: "openchat",
|
|
60
|
+
meta,
|
|
61
|
+
onboarding: openChatOnboardingAdapter,
|
|
62
|
+
capabilities: {
|
|
63
|
+
// Direct chat only — user installs the bot and talks to it 1:1
|
|
64
|
+
chatTypes: ["direct"],
|
|
65
|
+
media: false,
|
|
66
|
+
},
|
|
67
|
+
config: {
|
|
68
|
+
listAccountIds: (cfg) => {
|
|
69
|
+
const base = cfg.channels?.openchat as
|
|
70
|
+
| (OpenChatAccountConfig & {
|
|
71
|
+
accounts?: Record<string, OpenChatAccountConfig>;
|
|
72
|
+
})
|
|
73
|
+
| undefined;
|
|
74
|
+
if (process.env.OC_PRIVATE_KEY || base?.privateKeyFile || base?.privateKey) {
|
|
75
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
76
|
+
}
|
|
77
|
+
const accountKeys = Object.keys(base?.accounts ?? {});
|
|
78
|
+
return accountKeys.length ? accountKeys : [DEFAULT_ACCOUNT_ID];
|
|
79
|
+
},
|
|
80
|
+
resolveAccount: (cfg, accountId) => {
|
|
81
|
+
const id = accountId || DEFAULT_ACCOUNT_ID;
|
|
82
|
+
const base = cfg.channels?.openchat as
|
|
83
|
+
| (OpenChatAccountConfig & {
|
|
84
|
+
accounts?: Record<string, OpenChatAccountConfig>;
|
|
85
|
+
})
|
|
86
|
+
| undefined;
|
|
87
|
+
const account = base?.accounts?.[id];
|
|
88
|
+
|
|
89
|
+
const config: OpenChatAccountConfig = {
|
|
90
|
+
privateKeyFile: (account?.privateKeyFile || base?.privateKeyFile) as string | undefined,
|
|
91
|
+
privateKey: (account?.privateKey || base?.privateKey) as string | undefined,
|
|
92
|
+
enabled: (account?.enabled ?? base?.enabled ?? true) as boolean,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
accountId: id,
|
|
97
|
+
name: id,
|
|
98
|
+
enabled: config.enabled ?? true,
|
|
99
|
+
config,
|
|
100
|
+
privateKey: resolvePrivateKey(config),
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
104
|
+
isConfigured: (account) => Boolean(account.privateKey?.trim()),
|
|
105
|
+
describeAccount: (account) => ({
|
|
106
|
+
accountId: account.accountId,
|
|
107
|
+
name: account.name,
|
|
108
|
+
enabled: account.enabled,
|
|
109
|
+
configured: Boolean(account.privateKey?.trim()),
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { ResolvedOpenChatAccount } from "./channel.js";
|
|
4
|
+
import { getFactory } from "./factory.js";
|
|
5
|
+
import { getOpenChatRuntime } from "./runtime.js";
|
|
6
|
+
|
|
7
|
+
export function success(msg?: import("@open-ic/openchat-botclient-ts").Message) {
|
|
8
|
+
return {
|
|
9
|
+
message: msg?.toResponse(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Handles POST /openchat/execute_command.
|
|
15
|
+
*
|
|
16
|
+
* OpenChat sends a signed JWT in the x-oc-jwt header. The JWT contains
|
|
17
|
+
* the command name, arguments, and scope (which chat/user sent this).
|
|
18
|
+
* We verify it via the SDK, dispatch to the agent pipeline, then send
|
|
19
|
+
* the reply back via BotClient.sendMessage().
|
|
20
|
+
*/
|
|
21
|
+
export function makeExecuteCommandHandler(cfg: OpenClawConfig, account: ResolvedOpenChatAccount) {
|
|
22
|
+
return async function handleExecuteCommand(
|
|
23
|
+
req: IncomingMessage,
|
|
24
|
+
res: ServerResponse,
|
|
25
|
+
): Promise<boolean> {
|
|
26
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
27
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
28
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, x-oc-jwt");
|
|
29
|
+
|
|
30
|
+
if (req.method === "OPTIONS") {
|
|
31
|
+
res.statusCode = 204;
|
|
32
|
+
res.end();
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (req.method !== "POST") {
|
|
37
|
+
res.statusCode = 405;
|
|
38
|
+
res.end("Method Not Allowed");
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const jwt = req.headers["x-oc-jwt"] as string | undefined;
|
|
43
|
+
if (!jwt) {
|
|
44
|
+
res.statusCode = 400;
|
|
45
|
+
res.end(JSON.stringify({ error: "Missing x-oc-jwt header" }));
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!account.privateKey) {
|
|
50
|
+
res.statusCode = 500;
|
|
51
|
+
res.end(
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
error: "OpenChat bot not configured (missing privateKey)",
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let client;
|
|
60
|
+
try {
|
|
61
|
+
const factory = getFactory(account.privateKey);
|
|
62
|
+
client = factory.createClientFromCommandJwt(jwt);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
res.statusCode = 400;
|
|
65
|
+
res.end(JSON.stringify({ error: `Invalid JWT: ${String(err)}` }));
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (client.commandName !== "prompt") {
|
|
70
|
+
res.statusCode = 400;
|
|
71
|
+
res.end(
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
error: `Unknown command: ${client.commandName}`,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const promptText = client.stringArg("prompt");
|
|
80
|
+
if (!promptText?.trim()) {
|
|
81
|
+
res.statusCode = 400;
|
|
82
|
+
res.end(JSON.stringify({ error: "prompt argument is required" }));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const placeholder = (await client.createTextMessage("Thinking ...")).setFinalised(false);
|
|
87
|
+
|
|
88
|
+
// Respond immediately so OpenChat shows a placeholder while we process.
|
|
89
|
+
res.statusCode = 200;
|
|
90
|
+
res.setHeader("Content-Type", "application/json");
|
|
91
|
+
res.end(JSON.stringify(success(placeholder)));
|
|
92
|
+
|
|
93
|
+
// Dispatch to the agent pipeline asynchronously.
|
|
94
|
+
const core = getOpenChatRuntime();
|
|
95
|
+
const log = core.logging.getChildLogger({ channel: "openchat" });
|
|
96
|
+
const senderId = String(client.initiator ?? "unknown");
|
|
97
|
+
const messageId = String(Date.now());
|
|
98
|
+
|
|
99
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
100
|
+
cfg,
|
|
101
|
+
channel: "openchat",
|
|
102
|
+
accountId: account.accountId,
|
|
103
|
+
peer: { kind: "direct", id: senderId },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
107
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
108
|
+
agentId: route.agentId,
|
|
109
|
+
});
|
|
110
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
111
|
+
storePath,
|
|
112
|
+
sessionKey: route.sessionKey,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
116
|
+
channel: "OpenChat",
|
|
117
|
+
from: senderId,
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
previousTimestamp,
|
|
120
|
+
envelope: envelopeOptions,
|
|
121
|
+
body: promptText,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
125
|
+
Body: body,
|
|
126
|
+
BodyForAgent: promptText,
|
|
127
|
+
RawBody: promptText,
|
|
128
|
+
CommandBody: promptText,
|
|
129
|
+
From: `openchat:${senderId}`,
|
|
130
|
+
To: `openchat:${account.accountId}`,
|
|
131
|
+
SessionKey: route.sessionKey,
|
|
132
|
+
AccountId: route.accountId,
|
|
133
|
+
ChatType: "direct",
|
|
134
|
+
ConversationLabel: senderId,
|
|
135
|
+
SenderId: senderId,
|
|
136
|
+
Provider: "openchat",
|
|
137
|
+
Surface: "openchat",
|
|
138
|
+
MessageSid: messageId,
|
|
139
|
+
MessageSidFull: messageId,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// OpenChat command/response model: one command → one reply message.
|
|
143
|
+
// All messages created by a command BotClient share the same messageId, so we
|
|
144
|
+
// must call sendMessage exactly once. Accumulate all delivered text segments
|
|
145
|
+
// and send a single finalised message after dispatch completes.
|
|
146
|
+
const replyParts: string[] = [];
|
|
147
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
148
|
+
ctx: ctxPayload,
|
|
149
|
+
cfg,
|
|
150
|
+
dispatcherOptions: {
|
|
151
|
+
deliver: async (payload) => {
|
|
152
|
+
if (payload.text) {
|
|
153
|
+
replyParts.push(payload.text);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
onError: (err) => {
|
|
157
|
+
log.error(`[openchat] dispatch error: ${String(err)}`);
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const replyText = replyParts.join("\n\n").trim();
|
|
162
|
+
if (replyText) {
|
|
163
|
+
try {
|
|
164
|
+
const msg = await client.createTextMessage(replyText);
|
|
165
|
+
msg.setBlockLevelMarkdown(true);
|
|
166
|
+
msg.setFinalised(true);
|
|
167
|
+
await client.sendMessage(msg);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
log.error(`[openchat] failed to send reply: ${String(err)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return true;
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BotClientFactory } from "@open-ic/openchat-botclient-ts";
|
|
2
|
+
|
|
3
|
+
// Production OpenChat / Internet Computer constants.
|
|
4
|
+
// Override via environment variables for testing against a local replica.
|
|
5
|
+
const OC_IC_HOST = process.env.OC_IC_HOST ?? "https://icp-api.io";
|
|
6
|
+
const OC_PUBLIC_KEY = process.env.OC_PUBLIC_KEY ?? "";
|
|
7
|
+
const OC_USER_INDEX_CANISTER = process.env.OC_USER_INDEX_CANISTER ?? "";
|
|
8
|
+
const OC_STORAGE_INDEX_CANISTER =
|
|
9
|
+
process.env.OC_STORAGE_INDEX_CANISTER ?? "nbpzs-kqaaa-aaaar-qaaua-cai";
|
|
10
|
+
|
|
11
|
+
let cached: { factory: BotClientFactory; privateKey: string } | null = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns a BotClientFactory for the given private key, reusing the cached
|
|
15
|
+
* instance when the key hasn't changed.
|
|
16
|
+
*/
|
|
17
|
+
export function getFactory(privateKey: string): BotClientFactory {
|
|
18
|
+
if (cached?.privateKey === privateKey) {
|
|
19
|
+
return cached.factory;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!OC_PUBLIC_KEY) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"OC_PUBLIC_KEY environment variable is required. " +
|
|
25
|
+
"Set it to OpenChat's ES256 public key for JWT verification.",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Both keys may have literal \n in env vars — unescape them.
|
|
30
|
+
const factory = new BotClientFactory({
|
|
31
|
+
openchatPublicKey: OC_PUBLIC_KEY.replace(/\\n/g, "\n"),
|
|
32
|
+
icHost: OC_IC_HOST,
|
|
33
|
+
identityPrivateKey: privateKey,
|
|
34
|
+
openStorageCanisterId: OC_STORAGE_INDEX_CANISTER,
|
|
35
|
+
userIndexCanisterId: OC_USER_INDEX_CANISTER,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
cached = { factory, privateKey };
|
|
39
|
+
return factory;
|
|
40
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ChannelOnboardingAdapter, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
type OcConfig = { privateKeyFile?: string; privateKey?: string };
|
|
4
|
+
|
|
5
|
+
function setEnabled(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig {
|
|
6
|
+
return {
|
|
7
|
+
...cfg,
|
|
8
|
+
channels: {
|
|
9
|
+
...cfg.channels,
|
|
10
|
+
openchat: { ...cfg.channels?.openchat, enabled },
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isConfigured(cfg: OpenClawConfig): boolean {
|
|
16
|
+
const oc = cfg.channels?.openchat as OcConfig | undefined;
|
|
17
|
+
return Boolean(
|
|
18
|
+
process.env.OC_PRIVATE_KEY || oc?.privateKeyFile?.trim() || oc?.privateKey?.trim(),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const openChatOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
23
|
+
channel: "openchat",
|
|
24
|
+
getStatus: async ({ cfg }) => {
|
|
25
|
+
const configured = isConfigured(cfg);
|
|
26
|
+
return {
|
|
27
|
+
channel: "openchat",
|
|
28
|
+
configured,
|
|
29
|
+
statusLines: [configured ? "OpenChat: Configured" : "OpenChat: Not configured"],
|
|
30
|
+
quickstartScore: configured ? 1 : 0,
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
configure: async ({ cfg, prompter }) => {
|
|
34
|
+
const oc = cfg.channels?.openchat as OcConfig | undefined;
|
|
35
|
+
|
|
36
|
+
// Prefer file path — avoids PEM newline escaping issues in YAML.
|
|
37
|
+
// Users can also set OC_PRIVATE_KEY env var to skip this entirely.
|
|
38
|
+
const privateKeyFile = await prompter.text({
|
|
39
|
+
message:
|
|
40
|
+
"Path to your OpenChat bot PEM key file (e.g. ~/.openclaw/openchat-bot.pem)\n" +
|
|
41
|
+
" Leave blank to use the OC_PRIVATE_KEY env var instead.",
|
|
42
|
+
initialValue: oc?.privateKeyFile ?? "",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const next: OpenClawConfig = {
|
|
46
|
+
...cfg,
|
|
47
|
+
channels: {
|
|
48
|
+
...cfg.channels,
|
|
49
|
+
openchat: {
|
|
50
|
+
...cfg.channels?.openchat,
|
|
51
|
+
enabled: true,
|
|
52
|
+
privateKeyFile: String(privateKeyFile ?? "").trim() || undefined,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return { cfg: next, accountId: "default" };
|
|
57
|
+
},
|
|
58
|
+
disable: (cfg) => setEnabled(cfg, false),
|
|
59
|
+
};
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setOpenChatRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getOpenChatRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("OpenChat runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|