openclaw-webchat-plugin 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 ADDED
@@ -0,0 +1,99 @@
1
+ # openclaw-webchat-plugin
2
+
3
+ Browser WebChat channel for OpenClaw. Chat with your OpenClaw agents directly from a web browser — no third-party IM platform needed.
4
+
5
+ ## Prerequisites
6
+
7
+ - OpenClaw Gateway >= 2026.3.28
8
+ - A WebChat Chat Server running at a public (or reachable) address
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ openclaw plugins install openclaw-webchat-plugin
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ### 1. Deploy the Chat Server
19
+
20
+ On any machine with a public IP (or reachable from both browser and plugin), clone the [repo](https://github.com/wutao667/openclaw-wechat) and run:
21
+
22
+ ```bash
23
+ git clone https://github.com/wutao667/openclaw-wechat.git
24
+ cd openclaw-wechat/server
25
+ npm install
26
+ node server.js
27
+ ```
28
+
29
+ The Chat Server listens on port `3100`:
30
+ - Browser → `ws://<host>:3100/ws`
31
+ - Plugin → `ws://<host>:3100/plugin`
32
+
33
+ For production, add a TLS reverse proxy (Caddy/Nginx) for WSS support.
34
+
35
+ ### 2. Configure Channel
36
+
37
+ Edit `~/.openclaw/openclaw.json`:
38
+
39
+ ```json
40
+ {
41
+ "channels": {
42
+ "webchat": {
43
+ "enabled": true,
44
+ "serverUrl": "wss://your-domain.com/plugin",
45
+ "pluginId": "webchat-openclaw-plugin",
46
+ "agents": [
47
+ { "agentId": "main", "name": "我的助手" }
48
+ ]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ | Field | Description | Default |
55
+ |-------|-------------|---------|
56
+ | `enabled` | Enable this channel | `true` |
57
+ | `serverUrl` | Chat Server `/plugin` WebSocket URL | `ws://localhost:3100/plugin` |
58
+ | `agents` | Agents exposed to browser users | `[{ agentId: "nezha", name: "哪吒" }]` |
59
+ | `dmPolicy` | Direct message policy | `open` |
60
+
61
+ ### 3. Restart Gateway
62
+
63
+ ```bash
64
+ openclaw gateway restart
65
+ ```
66
+
67
+ ### 4. Open Browser
68
+
69
+ Visit `https://your-domain.com`, enter a username, select an agent, and start chatting.
70
+
71
+ ## Architecture
72
+
73
+ ```
74
+ Browser ──WS──→ Chat Server (public) ──WS──→ Plugin ──dispatch──→ OpenClaw Core → Agent
75
+ ```
76
+
77
+ The plugin initiates an outbound WebSocket connection to the Chat Server (long-connection, not webhook). This means OpenClaw instances behind NAT/firewalls can still connect as long as they have outbound internet access.
78
+
79
+ ## Multiple OpenClaw Instances
80
+
81
+ Each instance installs the plugin with a different `agentId`, all connecting to the same Chat Server. The server routes messages by `agentId`.
82
+
83
+ ```json
84
+ // Instance A
85
+ { "agents": [{ "agentId": "instance-a", "name": "Bot A" }] }
86
+
87
+ // Instance B
88
+ { "agents": [{ "agentId": "instance-b", "name": "Bot B" }] }
89
+ ```
90
+
91
+ ## Session Isolation
92
+
93
+ - Session key: `webchat:{userId}:{agentId}`
94
+ - Same user + same agent across browsers → shared history
95
+ - Different users → completely isolated
96
+
97
+ ## Development
98
+
99
+ Full source code at [github.com/wutao667/openclaw-wechat](https://github.com/wutao667/openclaw-wechat).
package/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
2
+ import { webchatPlugin } from "./src/channel.js";
3
+ import { setWebChatRuntime } from "./src/runtime.js";
4
+
5
+ const plugin = {
6
+ id: "webchat-openclaw-plugin",
7
+ name: "WebChat",
8
+ description: "Browser WebChat channel for OpenClaw",
9
+ configSchema: emptyPluginConfigSchema(),
10
+
11
+ register(api) {
12
+ setWebChatRuntime(api.runtime);
13
+ api.registerChannel({ plugin: webchatPlugin });
14
+ },
15
+ };
16
+
17
+ export default plugin;
@@ -0,0 +1,38 @@
1
+ {
2
+ "id": "webchat-openclaw-plugin",
3
+ "kind": "channel",
4
+ "channels": ["webchat"],
5
+ "name": "WebChat",
6
+ "description": "Browser-based WebChat channel for OpenClaw",
7
+ "configSchema": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "properties": {}
11
+ },
12
+ "channelConfigs": {
13
+ "webchat": {
14
+ "schema": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "properties": {
18
+ "enabled": { "type": "boolean" },
19
+ "serverUrl": { "type": "string", "description": "Chat Server WebSocket URL" },
20
+ "pluginId": { "type": "string" },
21
+ "agents": {
22
+ "type": "array",
23
+ "items": {
24
+ "type": "object",
25
+ "properties": {
26
+ "agentId": { "type": "string" },
27
+ "name": { "type": "string" }
28
+ },
29
+ "required": ["agentId"]
30
+ }
31
+ },
32
+ "allowFrom": { "type": "array", "items": { "type": "string" } },
33
+ "dmPolicy": { "type": "string", "enum": ["open", "allowlist", "pairing"] }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "openclaw-webchat-plugin",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "dependencies": {
7
+ "ws": "^8.18.0"
8
+ },
9
+ "peerDependencies": {
10
+ "openclaw": ">=2026.3.28"
11
+ },
12
+ "openclaw": {
13
+ "extensions": [
14
+ "./index.js"
15
+ ],
16
+ "channel": {
17
+ "id": "webchat",
18
+ "label": "WebChat",
19
+ "selectionLabel": "WebChat",
20
+ "blurb": "Browser chat channel for OpenClaw"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,27 @@
1
+ import { DEFAULT_ACCOUNT_ID, DEFAULT_PLUGIN_ID, DEFAULT_SERVER_URL } from "./const.js";
2
+
3
+ export function resolveWebChatConfig(cfg) {
4
+ if (!cfg || !cfg.channels) return {};
5
+ return cfg.channels.webchat || {};
6
+ }
7
+
8
+ export function listWebChatAccountIds(_cfg) {
9
+ return [DEFAULT_ACCOUNT_ID];
10
+ }
11
+
12
+ export function resolveDefaultWebChatAccountId(_cfg) {
13
+ return DEFAULT_ACCOUNT_ID;
14
+ }
15
+
16
+ export function resolveWebChatAccount(cfg, accountId = DEFAULT_ACCOUNT_ID) {
17
+ const section = resolveWebChatConfig(cfg);
18
+ return {
19
+ accountId,
20
+ enabled: section.enabled !== false,
21
+ serverUrl: section.serverUrl || DEFAULT_SERVER_URL,
22
+ pluginId: section.pluginId || DEFAULT_PLUGIN_ID,
23
+ agents: section.agents || [{ agentId: "nezha", name: "哪吒" }],
24
+ allowFrom: section.allowFrom || ["*"],
25
+ dmPolicy: section.dmPolicy || "open",
26
+ };
27
+ }
package/src/channel.js ADDED
@@ -0,0 +1,101 @@
1
+ import { CHANNEL_ID, TEXT_CHUNK_LIMIT } from "./const.js";
2
+ import { startWebChatWsClient, stopWebChatWsClient, sendOutgoingMessage } from "./ws-client.js";
3
+ import { listWebChatAccountIds, resolveWebChatAccount, resolveDefaultWebChatAccountId } from "./accounts.js";
4
+ import { getWebChatRuntime } from "./runtime.js";
5
+
6
+ export const webchatPlugin = {
7
+ id: CHANNEL_ID,
8
+ meta: {
9
+ label: "WebChat",
10
+ selectionLabel: "WebChat",
11
+ blurb: "Browser chat channel for OpenClaw",
12
+ },
13
+
14
+ capabilities: {
15
+ blockStreaming: true,
16
+ directChatOnly: true,
17
+ },
18
+
19
+ config: {
20
+ listAccountIds: listWebChatAccountIds,
21
+ resolveAccount: async ({ cfg, accountId }) => resolveWebChatAccount(cfg, accountId),
22
+ isConfigured: async ({ cfg, accountId }) => {
23
+ const account = resolveWebChatAccount(cfg, accountId);
24
+ return account.enabled;
25
+ },
26
+ describeAccount: async ({ cfg, accountId }) => {
27
+ const account = resolveWebChatAccount(cfg, accountId);
28
+ return {
29
+ label: CHANNEL_ID,
30
+ description: "WebChat channel",
31
+ configured: account.enabled,
32
+ inbound: true,
33
+ outbound: true,
34
+ };
35
+ },
36
+ resolveAllowFrom: async ({ cfg, accountId }) => {
37
+ const account = resolveWebChatAccount(cfg, accountId);
38
+ return { allowFrom: account.allowFrom };
39
+ },
40
+ },
41
+
42
+ security: {
43
+ resolveDmPolicy: async ({ cfg, accountId }) => {
44
+ const account = resolveWebChatAccount(cfg, accountId);
45
+ return account.dmPolicy;
46
+ },
47
+ },
48
+
49
+ messaging: {
50
+ normalizeTarget: async ({ to }) => {
51
+ return { target: to };
52
+ },
53
+ targetResolver: async ({ to }) => {
54
+ const result = { targets: [{ to, chatId: to }] };
55
+ return result;
56
+ },
57
+ },
58
+
59
+ outbound: {
60
+ deliveryMode: "gateway",
61
+ chunker: (text, limit) => getWebChatRuntime().channel.text.chunkMarkdownText(text, limit),
62
+ textChunkLimit: TEXT_CHUNK_LIMIT,
63
+ sendText: async ({ to, text, accountId, cfg }) => {
64
+ return sendOutgoingMessage({ to, text, accountId, cfg });
65
+ },
66
+ },
67
+
68
+ status: {
69
+ defaultRuntime: async ({ cfg }) => {
70
+ return {
71
+ accountId: resolveDefaultWebChatAccountId(cfg),
72
+ status: "starting",
73
+ };
74
+ },
75
+ collectStatusIssues: async ({ cfg, accountId }) => {
76
+ const account = resolveWebChatAccount(cfg, accountId);
77
+ const issues = [];
78
+ if (!account.enabled) {
79
+ issues.push({ kind: "warning", message: "WebChat channel is disabled" });
80
+ }
81
+ return issues;
82
+ },
83
+ buildChannelSummary: async ({ cfg, accountId }) => {
84
+ const account = resolveWebChatAccount(cfg, accountId);
85
+ return { label: CHANNEL_ID, summary: `WebChat: ${account.serverUrl}` };
86
+ },
87
+ },
88
+
89
+ gateway: {
90
+ startAccount: async ({ cfg, accountId }) => {
91
+ const account = resolveWebChatAccount(cfg, accountId);
92
+ const runtime = getWebChatRuntime();
93
+ startWebChatWsClient(runtime, account);
94
+ return { accountId };
95
+ },
96
+ logoutAccount: async ({ cfg, accountId }) => {
97
+ stopWebChatWsClient();
98
+ return { accountId };
99
+ },
100
+ },
101
+ };
package/src/const.js ADDED
@@ -0,0 +1,5 @@
1
+ export const CHANNEL_ID = "webchat";
2
+ export const DEFAULT_ACCOUNT_ID = "default";
3
+ export const DEFAULT_PLUGIN_ID = "webchat-openclaw-plugin";
4
+ export const DEFAULT_SERVER_URL = process.env.WEBCHAT_SERVER_URL || "ws://localhost:3100/plugin";
5
+ export const TEXT_CHUNK_LIMIT = 3500;
package/src/runtime.js ADDED
@@ -0,0 +1,6 @@
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
+
3
+ const { setRuntime: setWebChatRuntime, getRuntime: getWebChatRuntime } =
4
+ createPluginRuntimeStore("WebChat runtime not initialized");
5
+
6
+ export { setWebChatRuntime, getWebChatRuntime };
@@ -0,0 +1,257 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { WebSocket } from "ws";
5
+ import { CHANNEL_ID } from "./const.js";
6
+
7
+ let state = {
8
+ ws: null,
9
+ runtime: null,
10
+ account: null,
11
+ reconnectTimer: null,
12
+ heartbeatTimer: null,
13
+ reconnectAttempts: 0,
14
+ intentionalClose: false,
15
+ fullCfg: {},
16
+ };
17
+
18
+ function loadGatewayConfig() {
19
+ try {
20
+ const configPath = path.resolve(os.homedir(), ".openclaw/openclaw.json");
21
+ const content = fs.readFileSync(configPath, "utf8");
22
+ return JSON.parse(content);
23
+ } catch (err) {
24
+ console.warn("[webchat] could not load gateway config:", err.message);
25
+ return {};
26
+ }
27
+ }
28
+
29
+ function clearReconnectTimer() {
30
+ if (state.reconnectTimer) {
31
+ clearTimeout(state.reconnectTimer);
32
+ state.reconnectTimer = null;
33
+ }
34
+ }
35
+
36
+ function clearHeartbeatTimer() {
37
+ if (state.heartbeatTimer) {
38
+ clearInterval(state.heartbeatTimer);
39
+ state.heartbeatTimer = null;
40
+ }
41
+ }
42
+
43
+ function startHeartbeat() {
44
+ clearHeartbeatTimer();
45
+ state.heartbeatTimer = setInterval(() => {
46
+ if (state.ws?.readyState === WebSocket.OPEN) {
47
+ state.ws.send(JSON.stringify({ type: "ping", pluginId: state.account?.pluginId }));
48
+ }
49
+ }, 30_000);
50
+ }
51
+
52
+ function scheduleReconnect() {
53
+ if (state.intentionalClose || state.reconnectTimer) return;
54
+
55
+ state.reconnectAttempts += 1;
56
+ const delay = Math.min(30_000, Math.max(2_000, 2 ** state.reconnectAttempts * 1_000));
57
+
58
+ state.reconnectTimer = setTimeout(() => {
59
+ state.reconnectTimer = null;
60
+ connectWebSocket();
61
+ }, delay);
62
+ }
63
+
64
+ function connectWebSocket() {
65
+ const account = state.account;
66
+ if (!account?.serverUrl) return;
67
+
68
+ clearReconnectTimer();
69
+ clearHeartbeatTimer();
70
+
71
+ const ws = new WebSocket(account.serverUrl);
72
+ state.ws = ws;
73
+
74
+ ws.on("open", () => {
75
+ state.reconnectAttempts = 0;
76
+ ws.send(
77
+ JSON.stringify({
78
+ type: "register",
79
+ pluginId: account.pluginId,
80
+ agents: account.agents,
81
+ }),
82
+ );
83
+ startHeartbeat();
84
+ });
85
+
86
+ ws.on("message", async (data) => {
87
+ let message;
88
+ try {
89
+ message = JSON.parse(String(data));
90
+ } catch (err) {
91
+ console.error("[webchat] failed to parse websocket message", err);
92
+ return;
93
+ }
94
+
95
+ if (message.type === "incoming") {
96
+ try {
97
+ await dispatchIncoming(message);
98
+ } catch (err) {
99
+ console.error("[webchat] failed to dispatch incoming message", err);
100
+ }
101
+ return;
102
+ }
103
+
104
+ if (message.type === "registered") {
105
+ console.log("[webchat] registered with chat server");
106
+ return;
107
+ }
108
+
109
+ if (message.type === "agent_list") {
110
+ console.log("[webchat] agent list received", message.agents || []);
111
+ }
112
+ });
113
+
114
+ ws.on("close", () => {
115
+ if (state.ws === ws) {
116
+ state.ws = null;
117
+ }
118
+ clearHeartbeatTimer();
119
+ if (!state.intentionalClose) {
120
+ scheduleReconnect();
121
+ }
122
+ });
123
+
124
+ ws.on("error", (err) => {
125
+ console.error("[webchat] websocket error", err);
126
+ ws.close();
127
+ });
128
+ }
129
+
130
+ export function startWebChatWsClient(runtime, account) {
131
+ state.runtime = runtime;
132
+ state.account = account;
133
+ state.fullCfg = loadGatewayConfig();
134
+ state.intentionalClose = false;
135
+ connectWebSocket();
136
+ }
137
+
138
+ export function stopWebChatWsClient() {
139
+ state.intentionalClose = true;
140
+ clearReconnectTimer();
141
+ clearHeartbeatTimer();
142
+
143
+ if (state.ws) {
144
+ state.ws.close();
145
+ state.ws = null;
146
+ }
147
+ }
148
+
149
+ export function sendOutgoingMessage({ to, text, accountId, cfg }) {
150
+ const acc = state.account;
151
+ const rawTo = String(to || "");
152
+ let userId = rawTo.replace(/^webchat:/, "");
153
+ if (!userId) {
154
+ userId = rawTo.match(/webchat:([^:\s/]+)/)?.[1] || rawTo.match(/([^:\s/]+)$/)?.[1] || "";
155
+ }
156
+
157
+ const agentId = acc?.agents?.[0]?.agentId || "default";
158
+ const messageId = `webchat_out_${Date.now()}`;
159
+
160
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
161
+ throw new Error("WebChat websocket is not connected");
162
+ }
163
+
164
+ state.ws.send(
165
+ JSON.stringify({
166
+ type: "outgoing",
167
+ pluginId: acc?.pluginId,
168
+ agentId,
169
+ userId,
170
+ content: text,
171
+ messageId,
172
+ }),
173
+ );
174
+
175
+ return { channel: CHANNEL_ID, messageId, chatId: userId };
176
+ }
177
+
178
+ async function dispatchIncoming(message) {
179
+ const core = state.runtime;
180
+ if (!core) return;
181
+
182
+ const content = String(message.content || message.Body || "");
183
+ const userId = String(message.userId || "");
184
+ const userName = String(message.userName || userId);
185
+ const agentId = String(message.agentId || "");
186
+ const conversationId = String(message.conversationId || userId);
187
+ const acc = state.account;
188
+ const cfg = state.fullCfg || {};
189
+
190
+ const route = core.channel.routing.resolveAgentRoute({
191
+ cfg,
192
+ channel: CHANNEL_ID,
193
+ accountId: acc.accountId,
194
+ peer: { kind: "direct", id: conversationId },
195
+ agentId: agentId || acc.agents?.[0]?.agentId || "default",
196
+ });
197
+
198
+ route.sessionKey = `${CHANNEL_ID}:${userId}:${agentId}`;
199
+
200
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
201
+ agentId: route.agentId,
202
+ });
203
+
204
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
205
+ Body: content,
206
+ RawBody: content,
207
+ CommandBody: content,
208
+ MessageSid: message.messageId || `webchat_${Date.now()}`,
209
+ From: `${CHANNEL_ID}:${userId}`,
210
+ To: `${CHANNEL_ID}:${conversationId}`,
211
+ SenderId: userId,
212
+ SessionKey: route.sessionKey,
213
+ AccountId: route.accountId,
214
+ ChatType: "direct",
215
+ ConversationLabel: `user:${userName}`,
216
+ Timestamp: Date.now(),
217
+ Provider: CHANNEL_ID,
218
+ Surface: CHANNEL_ID,
219
+ OriginatingChannel: CHANNEL_ID,
220
+ OriginatingTo: `${CHANNEL_ID}:${conversationId}`,
221
+ CommandAuthorized: true,
222
+ });
223
+
224
+ await core.channel.session.recordInboundSession({
225
+ storePath,
226
+ sessionKey: ctxPayload.SessionKey || route.sessionKey,
227
+ ctx: ctxPayload,
228
+ updateLastRoute: {
229
+ sessionKey: route.mainSessionKey || route.sessionKey,
230
+ channel: CHANNEL_ID,
231
+ to: `${CHANNEL_ID}:${conversationId}`,
232
+ accountId: route.accountId,
233
+ },
234
+ });
235
+
236
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
237
+ ctx: ctxPayload,
238
+ cfg,
239
+ dispatcherOptions: {
240
+ deliver: async (payload) => {
241
+ if (!payload.text) return;
242
+ if (state.ws?.readyState === WebSocket.OPEN) {
243
+ state.ws.send(
244
+ JSON.stringify({
245
+ type: "outgoing",
246
+ pluginId: acc.pluginId,
247
+ agentId: message.agentId,
248
+ userId,
249
+ content: payload.text,
250
+ messageId: `webchat_reply_${Date.now()}`,
251
+ }),
252
+ );
253
+ }
254
+ },
255
+ },
256
+ });
257
+ }