nc-local-im-connector 0.2.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/.idea/.name ADDED
@@ -0,0 +1 @@
1
+ plugin.ts
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="MaterialThemeProjectNewConfig">
4
+ <option name="metadata">
5
+ <MTProjectMetadataState>
6
+ <option name="migrated" value="true" />
7
+ <option name="pristineConfig" value="false" />
8
+ <option name="userId" value="654a8689:19b960f5909:-7ffe" />
9
+ </MTProjectMetadataState>
10
+ </option>
11
+ </component>
12
+ </project>
@@ -0,0 +1,75 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AutoImportSettings">
4
+ <option name="autoReloadType" value="SELECTIVE" />
5
+ </component>
6
+ <component name="ChangeListManager">
7
+ <list default="true" id="72d8575a-9a43-4cdf-a196-80b2045c8c32" name="更改" comment="" />
8
+ <option name="SHOW_DIALOG" value="false" />
9
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
10
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
11
+ <option name="LAST_RESOLUTION" value="IGNORE" />
12
+ </component>
13
+ <component name="EmbeddingIndexingInfo">
14
+ <option name="cachedIndexableFilesCount" value="8" />
15
+ <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
16
+ </component>
17
+ <component name="McpProjectServerCommands">
18
+ <commands />
19
+ <urls />
20
+ </component>
21
+ <component name="ProjectColorInfo">{
22
+ &quot;associatedIndex&quot;: 8,
23
+ &quot;fromUser&quot;: false
24
+ }</component>
25
+ <component name="ProjectId" id="3Bk7kwJhq7JLlNygBJKC2xPKVrZ" />
26
+ <component name="ProjectViewState">
27
+ <option name="hideEmptyMiddlePackages" value="true" />
28
+ <option name="showLibraryContents" value="true" />
29
+ </component>
30
+ <component name="PropertiesComponent">{
31
+ &quot;keyToString&quot;: {
32
+ &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
33
+ &quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
34
+ &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
35
+ &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
36
+ &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
37
+ &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
38
+ &quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
39
+ &quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;,
40
+ &quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
41
+ &quot;last_opened_file_path&quot;: &quot;C:/Users/15015/Desktop/plugins&quot;,
42
+ &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
43
+ &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
44
+ &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
45
+ &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
46
+ &quot;nodejs_package_manager_path&quot;: &quot;yarn&quot;,
47
+ &quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
48
+ &quot;ts.external.directory.path&quot;: &quot;D:\\jetapp\\WebStorm\\plugins\\javascript-plugin\\jsLanguageServicesImpl\\external&quot;,
49
+ &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
50
+ }
51
+ }</component>
52
+ <component name="SharedIndexes">
53
+ <attachedChunks>
54
+ <set>
55
+ <option value="bundled-js-predefined-d6986cc7102b-3bd3a6803838-JavaScript-WS-261.22158.274" />
56
+ </set>
57
+ </attachedChunks>
58
+ </component>
59
+ <component name="TaskManager">
60
+ <task active="true" id="Default" summary="默认任务">
61
+ <changelist id="72d8575a-9a43-4cdf-a196-80b2045c8c32" name="更改" comment="" />
62
+ <created>1775021361019</created>
63
+ <option name="number" value="Default" />
64
+ <option name="presentableId" value="Default" />
65
+ <updated>1775021361019</updated>
66
+ <workItem from="1775021362043" duration="1702000" />
67
+ <workItem from="1775023777571" duration="3938000" />
68
+ <workItem from="1775034040086" duration="41000" />
69
+ </task>
70
+ <servers />
71
+ </component>
72
+ <component name="TypeScriptGeneratedFilesManager">
73
+ <option name="version" value="3" />
74
+ </component>
75
+ </project>
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # My Chat Plugin - HTTP Webhook 版
2
+
3
+ 通过 HTTP Webhook 接收外部消息,简单易用。
4
+
5
+ ## 文件结构
6
+
7
+ ```
8
+ my-chat-plugin/
9
+ ├── openclaw.plugin.json # 插件元信息
10
+ ├── plugin.ts # 核心插件代码(HTTP Webhook 模式)
11
+ ├── package.json # NPM 依赖配置
12
+ └── README.md # 本文件
13
+ ```
14
+
15
+ ## 快速开始
16
+
17
+ ### 1. 安装插件
18
+
19
+ 将插件目录放入 OpenClaw 扩展目录:
20
+
21
+ ```bash
22
+ cp -r my-chat-plugin ~/.openclaw/extensions/
23
+ ```
24
+
25
+ ### 2. 配置插件
26
+
27
+ 在 OpenClaw 配置文件中添加:
28
+
29
+ ```json
30
+ {
31
+ "channels": {
32
+ "my-chat-plugin": {
33
+ "enabled": true,
34
+ "port": 3000,
35
+ "replyUrl": "https://your-server.com/reply",
36
+ "systemPrompt": "你是一个助手...",
37
+ "gatewayToken": ""
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ **配置项说明:**
44
+
45
+ | 配置项 | 默认值 | 说明 |
46
+ |--------|--------|------|
47
+ | `enabled` | `true` | 是否启用 |
48
+ | `port` | `3000` | HTTP Webhook 监听端口 |
49
+ | `replyUrl` | `""` | AI 回复发送到的 Webhook URL |
50
+ | `systemPrompt` | `""` | 自定义 System Prompt |
51
+ | `gatewayToken` | `""` | Gateway 认证 Token |
52
+
53
+ ### 3. 启动后配置
54
+
55
+ 外部消息系统需要配置 **POST 目标**:
56
+
57
+ ```
58
+ http://<你的服务器>:<port>/webhook
59
+ ```
60
+
61
+ ## 消息格式
62
+
63
+ ### 发送消息到插件(外部 → OpenClaw)
64
+
65
+ POST `http://<你的服务器>:<port>/webhook`
66
+
67
+ ```json
68
+ {
69
+ "messageId": "msg-001",
70
+ "senderId": "user-123",
71
+ "senderName": "张三",
72
+ "content": "你好,请帮我查一下天气",
73
+ "conversationId": "conv-456",
74
+ "isDirect": true
75
+ }
76
+ ```
77
+
78
+ **字段说明:**
79
+
80
+ | 字段 | 必填 | 说明 |
81
+ |------|------|------|
82
+ | `messageId` | 建议 | 消息唯一ID(用于去重) |
83
+ | `senderId` | **必填** | 发送者ID |
84
+ | `senderName` | 建议 | 发送者名称 |
85
+ | `content` | **必填** | 消息内容 |
86
+ | `conversationId` | 否 | 会话ID(群聊时用) |
87
+ | `isDirect` | 否 | 是否单聊(默认 true 单聊) |
88
+
89
+ ### 插件回复(OpenClaw → 外部)
90
+
91
+ 插件处理完消息后,会 POST 到配置的 `replyUrl`:
92
+
93
+ ```json
94
+ {
95
+ "senderId": "user-123",
96
+ "content": "今天北京天气晴,温度15-25度...",
97
+ "inReplyTo": "msg-001",
98
+ "conversationId": "conv-456",
99
+ "channel": "my-chat",
100
+ "timestamp": 1743400000000
101
+ }
102
+ ```
103
+
104
+ ### 测试命令
105
+
106
+ ```bash
107
+ # 模拟发送消息
108
+ curl -X POST http://localhost:3000/webhook \
109
+ -H "Content-Type: application/json" \
110
+ -d '{
111
+ "messageId": "test-001",
112
+ "senderId": "user-001",
113
+ "senderName": "测试用户",
114
+ "content": "你好"
115
+ }'
116
+ ```
117
+
118
+ ## Gateway Methods
119
+
120
+ 插件注册了两个 Gateway Method,供其他插件/Agent 调用:
121
+
122
+ ### 发送消息
123
+
124
+ ```javascript
125
+ await gateway.methods['my-chat-plugin.send']({
126
+ target: 'user-123',
127
+ text: 'Hello!'
128
+ })
129
+ ```
130
+
131
+ ### 查询状态
132
+
133
+ ```javascript
134
+ await gateway.methods['my-chat-plugin.status']()
135
+ ```
136
+
137
+ ## 与钉钉插件对比
138
+
139
+ | 对比项 | 钉钉插件 | HTTP 插件 |
140
+ |--------|----------|-----------|
141
+ | 消息接收 | 钉钉 Stream 推送 | 被动接收 POST |
142
+ | 消息回复 | 钉钉消息 API | POST 到 replyUrl |
143
+ | 复杂度 | 高(含 AccessToken、AI Card 等) | 低(纯 HTTP) |
144
+ | 适用场景 | 企业内部钉钉机器人 | 任意支持 Webhook 的系统 |
145
+
146
+ ## 下一步
147
+
148
+ 1. 配置 `replyUrl` 指向你的消息服务
149
+ 2. 在 `systemPrompt` 中添加你的业务逻辑指示
150
+ 3. 参考钉钉插件实现媒体文件上传(图片、文件等)
@@ -0,0 +1,49 @@
1
+ {
2
+ "id": "nc-local-im-connector",
3
+ "name": "nc-local-im-connector",
4
+ "description": "Local IM Channel Plugin for OpenClaw - 提供 HTTP 和 WebSocket 接口",
5
+ "version": "1.0.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "enabled": {
11
+ "type": "boolean",
12
+ "default": true,
13
+ "description": "启用或禁用本地 IM 连接器"
14
+ },
15
+ "clientWsUrl":{
16
+ "type": "string",
17
+ "default": "ws:192.168.100.168:8080",
18
+ "description": "远程服务"
19
+ },
20
+ "connectionMode": {
21
+ "type": "string",
22
+ "default": "client",
23
+ "description": "模式"
24
+ },
25
+ "wsPort": {
26
+ "type": "number",
27
+ "default": 3001,
28
+ "description": "WebSocket 服务端口"
29
+ },
30
+ "httpPort": {
31
+ "type": "number",
32
+ "default": 3002,
33
+ "description": "HTTP 服务端口"
34
+ },
35
+ "gatewayToken": {
36
+ "type": "string",
37
+ "description": "Gateway 认证令牌"
38
+ },
39
+ "tokenType": {
40
+ "type": "string",
41
+ "default": "Bearer",
42
+ "description": "令牌类型"
43
+ }
44
+ }
45
+ },
46
+ "channels": [
47
+ "nc-local-im-connector"
48
+ ]
49
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "nc-local-im-connector",
3
+ "version": "0.2.0",
4
+ "description": "能诚信聊天插件",
5
+ "main": "plugin.ts",
6
+ "scripts": {
7
+ "build": "tsc plugin.ts --outDir dist --esModuleInterop --target ES2020 --module commonjs",
8
+ "dev": "tsc plugin.ts --esModuleInterop --target ES2020 --module commonjs --watch"
9
+ },
10
+ "openclaw": {
11
+ "extensions": [
12
+ "./plugin.ts"
13
+ ]
14
+ },
15
+ "dependencies": {
16
+ "axios": "^1.6.0",
17
+ "clawdbot": "^2026.1.24-3",
18
+ "ws":"^8.20.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^20.0.0",
22
+ "typescript": "^5.0.0"
23
+ }
24
+ }
package/plugin.ts ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Local IM Connector Plugin for Moltbot/OpenClaw
3
+ * * 提供本地 WebSocket 和 HTTP 服务,允许外部应用通过标准接口与 OpenClaw 智能体对话。
4
+ * * [New] 支持 Client 长连接模式(类似钉钉 Stream 模式),主动连接外部网关并保持心跳。
5
+ */
6
+
7
+ import WebSocket, { WebSocketServer } from 'ws';
8
+ import express from 'express';
9
+ import type { ClawdbotPluginApi, PluginRuntime, ClawdbotConfig } from 'clawdbot/plugin-sdk';
10
+ import * as http from 'http';
11
+
12
+ export const id = 'nc-local-im-connector';
13
+
14
+ let runtime: PluginRuntime | null = null;
15
+
16
+ function getRuntime(): PluginRuntime {
17
+ if (!runtime) throw new Error('Local IM runtime not initialized');
18
+ return runtime;
19
+ }
20
+
21
+ // ============ 配置管理 ============
22
+
23
+ function getConfig(cfg: ClawdbotConfig) {
24
+ return (cfg?.channels as any)?.[id] || {};
25
+ }
26
+
27
+ // ============ 核心 API 调用 ============
28
+
29
+ interface SessionContext {
30
+ channel: string;
31
+ accountId: string;
32
+ chatType: 'direct' | 'group';
33
+ peerId: string;
34
+ conversationId?: string;
35
+ }
36
+
37
+ function buildSessionContext(userId: string, conversationId?: string): SessionContext {
38
+ return {
39
+ channel: 'nc-local-im-connector',
40
+ accountId: '__default__',
41
+ chatType: 'direct',
42
+ peerId: userId,
43
+ conversationId,
44
+ };
45
+ }
46
+
47
+ interface GatewayOptions {
48
+ userContent: string;
49
+ sessionContext: SessionContext;
50
+ peerKind?: 'direct' | 'group';
51
+ peerId?: string;
52
+ gatewayPort?: number;
53
+ log?: any;
54
+ gatewayToken?: string;
55
+ tokenType?: 'Bearer' | 'ApiKey';
56
+ }
57
+
58
+ /**
59
+ * 封装与 OpenClaw Gateway 的流式通信(与 plugin.ts 保持一致)
60
+ */
61
+ async function* streamFromGateway(options: GatewayOptions): AsyncGenerator<string, void, unknown> {
62
+ const { userContent, sessionContext, peerId, gatewayPort, log, gatewayToken, tokenType = 'Bearer' } = options;
63
+ const rt = getRuntime();
64
+ const port = gatewayPort || rt.gateway?.port || 18789;
65
+ const gatewayUrl = `http://127.0.0.1:${port}/v1/chat/completions`;
66
+
67
+ const messages = [
68
+ { role: 'user', content: userContent }
69
+ ];
70
+
71
+ const headers: Record<string, string> = {
72
+ 'Content-Type': 'application/json',
73
+ 'X-OpenClaw-Agent-Id': 'main',
74
+ };
75
+
76
+ const memoryUser = `${sessionContext.channel}:${sessionContext.accountId}:${sessionContext.peerId}`;
77
+ headers['X-OpenClaw-Memory-User'] = Buffer.from(memoryUser, 'utf-8').toString('base64');
78
+
79
+ if (gatewayToken) {
80
+ if (tokenType === 'Bearer') {
81
+ headers['Authorization'] = `Bearer ${gatewayToken}`;
82
+ } else if (tokenType === 'ApiKey') {
83
+ headers['X-API-Key'] = gatewayToken;
84
+ }
85
+ log?.info?.(`[LocalIM] 使用 ${tokenType} Token 认证`);
86
+ }
87
+
88
+ const response = await fetch(gatewayUrl, {
89
+ method: 'POST',
90
+ headers,
91
+ body: JSON.stringify({
92
+ model: 'main',
93
+ messages,
94
+ stream: true,
95
+ user: JSON.stringify(sessionContext),
96
+ }),
97
+ });
98
+
99
+ if (!response.ok || !response.body) {
100
+ const errText = response.body ? await response.text() : '(no body)';
101
+ if (response.status === 401 || response.status === 403) {
102
+ if (!gatewayToken) {
103
+ throw new Error(`Gateway 认证失败 (${response.status}): Gateway 需要认证,请在插件配置中设置 gatewayToken`);
104
+ } else {
105
+ throw new Error(`Gateway 认证失败 (${response.status}): Token 无效或已过期,请检查 gatewayToken 配置`);
106
+ }
107
+ }
108
+ throw new Error(`Gateway error: ${response.status} - ${errText}`);
109
+ }
110
+
111
+ const reader = response.body.getReader();
112
+ const decoder = new TextDecoder();
113
+ let buffer = '';
114
+
115
+ while (true) {
116
+ const { done, value } = await reader.read();
117
+ if (done) break;
118
+
119
+ buffer += decoder.decode(value, { stream: true });
120
+ const lines = buffer.split('\n');
121
+ buffer = lines.pop() || '';
122
+
123
+ for (const line of lines) {
124
+ if (!line.startsWith('data: ')) continue;
125
+ const data = line.slice(6).trim();
126
+ if (data === '[DONE]') return;
127
+
128
+ try {
129
+ const chunk = JSON.parse(data);
130
+ const content = chunk.choices?.[0]?.delta?.content;
131
+ if (content) yield content;
132
+ } catch {}
133
+ }
134
+ }
135
+ }
136
+
137
+ // ============ 插件定义 ============
138
+
139
+ const meta = {
140
+ id: 'nc-local-im-connector',
141
+ label: 'Local IM',
142
+ selectionLabel: 'Local IM (HTTP/WS 本地接入与长连接)',
143
+ docsPath: '/channels/nc-local-im-connector',
144
+ docsLabel: 'nc-local-im-connector',
145
+ blurb: '提供本地 HTTP 和 WebSocket 接口,并支持作为长连接客户端(类似钉钉模式)接入大模型。',
146
+ order: 80,
147
+ aliases: ['local', 'local-im'],
148
+ };
149
+
150
+ const localImPlugin = {
151
+ id: 'nc-local-im-connector',
152
+ meta,
153
+ capabilities: {
154
+ chatTypes: ['direct'],
155
+ reactions: false,
156
+ threads: false,
157
+ media: false,
158
+ nativeCommands: false,
159
+ blockStreaming: false,
160
+ },
161
+ reload: { configPrefixes: ['channels.nc-local-im-connector'] },
162
+ configSchema: {
163
+ schema: {
164
+ type: 'object',
165
+ additionalProperties: false,
166
+ properties: {
167
+ enabled: { type: 'boolean', default: true },
168
+ // 新增长连接模式选择
169
+ connectionMode: { type: 'string', enum: ['server', 'client'], default: 'server', description: '连接模式' },
170
+ clientWsUrl: { type: 'string',default:'ws:192.168.100.168:8080' , description: '作为长连接 Client 时,连接的目标 WebSocket 地址' },
171
+ wsPort: { type: 'number', default: 3001, description: 'Server模式: WebSocket 监听端口' },
172
+ httpPort: { type: 'number', default: 3002, description: 'Server模式: HTTP 监听端口' },
173
+ gatewayToken: { type: 'string', description: 'Gateway 认证 Token' },
174
+ tokenType: { type: 'string', enum: ['Bearer', 'ApiKey'], default: 'Bearer' },
175
+ name: { type: 'string', default: 'Local IM Channel' },
176
+ },
177
+ },
178
+ uiHints: {
179
+ enabled: { label: '启用 Local IM' },
180
+ connectionMode: { label: '运行模式 (Server:本地监听 / Client:主动长连接)' },
181
+ clientWsUrl: { label: '目标 WS 地址 (Client 模式)' },
182
+ wsPort: { label: 'WebSocket 端口 (Server 模式)' },
183
+ httpPort: { label: 'HTTP 端口 (Server 模式)' },
184
+ gatewayToken: { label: 'Gateway Token' },
185
+ tokenType: { label: 'Token 类型' },
186
+ name: { label: '账号名称' },
187
+ },
188
+ },
189
+ config: {
190
+ listAccountIds: (cfg: ClawdbotConfig) => {
191
+ const config = getConfig(cfg);
192
+ return config.accounts ? Object.keys(config.accounts) : ['__default__'];
193
+ },
194
+ resolveAccount: (cfg: ClawdbotConfig, accountId?: string) => {
195
+ const config = getConfig(cfg);
196
+ const id = accountId || '__default__';
197
+ if (config.accounts?.[id]) {
198
+ return {
199
+ accountId: id,
200
+ config: { ...config, ...config.accounts[id] },
201
+ enabled: config.accounts[id].enabled !== false
202
+ };
203
+ }
204
+ return { accountId: '__default__', config, enabled: config.enabled !== false };
205
+ },
206
+ defaultAccountId: () => '__default__',
207
+ isConfigured: (account: any) => {
208
+ const config = account?.config || {};
209
+ if (config.connectionMode === 'client') {
210
+ return Boolean(config.clientWsUrl); // Client 模式下必须配置目标地址
211
+ }
212
+ return Boolean(config.wsPort && config.httpPort); // Server 模式验证端口
213
+ },
214
+ describeAccount: (account: any) => ({
215
+ accountId: account.accountId,
216
+ name: account.config?.name || 'Local IM',
217
+ enabled: account.enabled,
218
+ configured: account.config?.connectionMode === 'client'
219
+ ? Boolean(account.config?.clientWsUrl)
220
+ : Boolean(account.config?.wsPort && account.config?.httpPort),
221
+ }),
222
+ },
223
+ gateway: {
224
+ startAccount: async (ctx: any) => {
225
+ const { account, cfg, abortSignal } = ctx;
226
+ const config = account.config;
227
+ const mode = config.connectionMode || 'server';
228
+
229
+ let stopped = false;
230
+ let doStop = (reason: string) => {};
231
+
232
+ if (mode === 'server') {
233
+ // ==========================================
234
+ // SERVER 模式:本地监听 (增强版:附带长连接心跳与 SSE 支持)
235
+ // ==========================================
236
+ const wsPort = config.wsPort || 3001;
237
+ const httpPort = config.httpPort || 3002;
238
+ ctx.log?.info(`[LocalIM-Server] 正在启动服务... (WS: ${wsPort}, HTTP: ${httpPort})`);
239
+
240
+ // 1. WebSocket 服务 (带长连接心跳)
241
+ const wss = new WebSocketServer({ port: wsPort });
242
+ const aliveClients = new WeakSet<WebSocket>();
243
+
244
+ // 服务端心跳检测:每 30 秒清理一次断开的死连接
245
+ const heartbeatInterval = setInterval(() => {
246
+ wss.clients.forEach((ws) => {
247
+ if (!aliveClients.has(ws)) return ws.terminate();
248
+ aliveClients.delete(ws);
249
+ ws.ping();
250
+ });
251
+ }, 30000);
252
+
253
+ wss.on('connection', (ws) => {
254
+ aliveClients.add(ws);
255
+ ws.on('pong', () => aliveClients.add(ws)); // 收到 pong 则更新活跃状态
256
+
257
+ ws.on('message', async (msg) => {
258
+ try {
259
+ const data = JSON.parse(msg.toString());
260
+ const { userId, conversationId, content } = data;
261
+ if (!userId || !content) return ws.send(JSON.stringify({ error: 'invalid payload' }));
262
+
263
+ const sessionContext = buildSessionContext(userId, conversationId);
264
+ let reply = '';
265
+
266
+ for await (const chunk of streamFromGateway({
267
+ userContent: content,
268
+ sessionContext,
269
+ peerId: userId,
270
+ gatewayPort: cfg.gateway?.port,
271
+ log: ctx.log,
272
+ gatewayToken: config.gatewayToken,
273
+ tokenType: config.tokenType || 'Bearer',
274
+ })) {
275
+ reply += chunk;
276
+ ws.send(JSON.stringify({ type: 'stream', conversationId, content: reply }));
277
+ }
278
+ ws.send(JSON.stringify({ type: 'done', conversationId, content: reply }));
279
+ } catch (err: any) {
280
+ ctx.log?.error(`[LocalIM-Server] WS 处理错误: ${err.message}`);
281
+ ws.send(JSON.stringify({ error: err.message }));
282
+ }
283
+ });
284
+ });
285
+
286
+ // 2. HTTP 服务 (增加 SSE 接口支持长连接)
287
+ const app = express();
288
+ app.use(express.json());
289
+
290
+ // 原有 REST 接口:等待完成后返回
291
+ app.post('/chat', async (req, res) => {
292
+ const { userId, conversationId, content } = req.body;
293
+ if (!userId || !content) return res.status(400).json({ error: 'invalid params' });
294
+
295
+ const sessionContext = buildSessionContext(userId, conversationId);
296
+ let reply = '';
297
+ try {
298
+ for await (const chunk of streamFromGateway({
299
+ userContent: content, sessionContext, peerId: userId, gatewayPort: cfg.gateway?.port,
300
+ log: ctx.log, gatewayToken: config.gatewayToken, tokenType: config.tokenType || 'Bearer'
301
+ })) { reply += chunk; }
302
+ res.json({ reply });
303
+ } catch (err: any) {
304
+ res.status(500).json({ error: err.message });
305
+ }
306
+ });
307
+
308
+ // 新增 SSE 接口:支持单向 HTTP 长连接流式输出
309
+ app.post('/chat/stream', async (req, res) => {
310
+ const { userId, conversationId, content } = req.body;
311
+ if (!userId || !content) return res.status(400).json({ error: 'invalid params' });
312
+
313
+ res.setHeader('Content-Type', 'text/event-stream');
314
+ res.setHeader('Cache-Control', 'no-cache');
315
+ res.setHeader('Connection', 'keep-alive');
316
+
317
+ const sessionContext = buildSessionContext(userId, conversationId);
318
+ try {
319
+ for await (const chunk of streamFromGateway({
320
+ userContent: content, sessionContext, peerId: userId, gatewayPort: cfg.gateway?.port,
321
+ log: ctx.log, gatewayToken: config.gatewayToken, tokenType: config.tokenType || 'Bearer'
322
+ })) {
323
+ res.write(`data: ${JSON.stringify({ type: 'stream', conversationId, chunk })}\n\n`);
324
+ }
325
+ res.write(`data: ${JSON.stringify({ type: 'done', conversationId })}\n\n`);
326
+ res.end();
327
+ } catch (err: any) {
328
+ res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
329
+ res.end();
330
+ }
331
+ });
332
+
333
+ const httpServer = http.createServer(app);
334
+ httpServer.listen(httpPort);
335
+ ctx.log?.info(`[LocalIM-Server] 服务启动成功!`);
336
+
337
+ doStop = (reason: string) => {
338
+ if (stopped) return;
339
+ stopped = true;
340
+ ctx.log?.info(`[LocalIM-Server] 停止服务 (${reason})...`);
341
+ clearInterval(heartbeatInterval);
342
+ try { wss.close(); httpServer.close(); } catch (err: any) {}
343
+ };
344
+
345
+ } else if (mode === 'client') {
346
+ // ==========================================
347
+ // CLIENT 模式:主动长连接 (参考钉钉 Stream 模式)
348
+ // ==========================================
349
+ let wsClient: WebSocket | null = null;
350
+ let reconnectTimer: NodeJS.Timeout | null = null;
351
+ let pingTimer: NodeJS.Timeout | null = null;
352
+
353
+ const connectClient = () => {
354
+ if (stopped) return;
355
+ if (!config.clientWsUrl) {
356
+ ctx.log?.error('[LocalIM-Client] 未配置 clientWsUrl,无法启动长连接');
357
+ return;
358
+ }
359
+
360
+ ctx.log?.info(`[LocalIM-Client] 尝试长连接至: ${config.clientWsUrl}`);
361
+ try {
362
+ wsClient = new WebSocket(config.clientWsUrl);
363
+
364
+ wsClient.on('open', () => {
365
+ ctx.log?.info('[LocalIM-Client] 长连接建立成功');
366
+ // 客户端主动发起心跳保活
367
+ pingTimer = setInterval(() => {
368
+ if (wsClient?.readyState === WebSocket.OPEN) wsClient.ping();
369
+ }, 30000);
370
+ });
371
+
372
+ wsClient.on('message', async (msg) => {
373
+ try {
374
+ const data = JSON.parse(msg.toString());
375
+ const { userId, conversationId, content } = data;
376
+ if (!userId || !content) return;
377
+
378
+ const sessionContext = buildSessionContext(userId, conversationId);
379
+ let reply = '';
380
+
381
+ for await (const chunk of streamFromGateway({
382
+ userContent: content, sessionContext, peerId: userId, gatewayPort: cfg.gateway?.port,
383
+ log: ctx.log, gatewayToken: config.gatewayToken, tokenType: config.tokenType || 'Bearer'
384
+ })) {
385
+ reply += chunk;
386
+ if (wsClient?.readyState === WebSocket.OPEN) {
387
+ wsClient.send(JSON.stringify({ type: 'stream', conversationId, content: reply }));
388
+ }
389
+ }
390
+ if (wsClient?.readyState === WebSocket.OPEN) {
391
+ wsClient.send(JSON.stringify({ type: 'done', conversationId, content: reply }));
392
+ }
393
+ } catch (err: any) {
394
+ ctx.log?.error(`[LocalIM-Client] 消息处理异常: ${err.message}`);
395
+ if (wsClient?.readyState === WebSocket.OPEN) {
396
+ wsClient.send(JSON.stringify({ error: err.message }));
397
+ }
398
+ }
399
+ });
400
+
401
+ wsClient.on('close', () => {
402
+ ctx.log?.warn('[LocalIM-Client] 长连接已断开');
403
+ cleanupClient();
404
+ scheduleReconnect();
405
+ });
406
+
407
+ wsClient.on('error', (err) => {
408
+ ctx.log?.error(`[LocalIM-Client] WebSocket 异常: ${err.message}`);
409
+ // Error 之后通常会自动触发 close,交由 close 处理重连
410
+ });
411
+
412
+ } catch (err: any) {
413
+ ctx.log?.error(`[LocalIM-Client] 创建连接失败: ${err.message}`);
414
+ scheduleReconnect();
415
+ }
416
+ };
417
+
418
+ const cleanupClient = () => {
419
+ if (pingTimer) clearInterval(pingTimer);
420
+ if (wsClient) {
421
+ wsClient.removeAllListeners();
422
+ if (wsClient.readyState === WebSocket.OPEN) wsClient.close();
423
+ wsClient = null;
424
+ }
425
+ };
426
+
427
+ const scheduleReconnect = () => {
428
+ if (stopped) return;
429
+ ctx.log?.info('[LocalIM-Client] 5秒后尝试断线重连...');
430
+ reconnectTimer = setTimeout(connectClient, 5000);
431
+ };
432
+
433
+ // 启动客户端连接
434
+ connectClient();
435
+
436
+ doStop = (reason: string) => {
437
+ if (stopped) return;
438
+ stopped = true;
439
+ ctx.log?.info(`[LocalIM-Client] 停止客户端长连接 (${reason})...`);
440
+ if (reconnectTimer) clearTimeout(reconnectTimer);
441
+ cleanupClient();
442
+ };
443
+ }
444
+
445
+ const rt = getRuntime();
446
+ rt.channel.activity.record('nc-local-im-connector', account.accountId, 'start');
447
+
448
+ return new Promise((resolve) => {
449
+ if (abortSignal) {
450
+ abortSignal.addEventListener('abort', () => {
451
+ doStop('abortSignal');
452
+ rt.channel.activity.record('nc-local-im-connector', account.accountId, 'stop');
453
+ resolve({
454
+ stop: () => doStop('manual'),
455
+ isHealthy: () => !stopped,
456
+ });
457
+ });
458
+ }
459
+ });
460
+ },
461
+ },
462
+ status: {
463
+ defaultRuntime: { accountId: '__default__', running: false, lastStartAt: null, lastStopAt: null, lastError: null },
464
+ probe: async () => ({ ok: true }),
465
+ buildChannelSummary: ({ snapshot }: any) => ({
466
+ configured: true,
467
+ running: snapshot?.running ?? false,
468
+ lastStartAt: snapshot?.lastStartAt ?? null,
469
+ lastStopAt: snapshot?.lastStopAt ?? null,
470
+ lastError: snapshot?.lastError ?? null,
471
+ }),
472
+ },
473
+ };
474
+
475
+ // ============ 插件注册 ============
476
+
477
+ const plugin = {
478
+ id: 'nc-local-im-connector',
479
+ name: 'Local IM Channel',
480
+ description: 'Provide local WebSocket/HTTP endpoints and Outbound Stream connection to chat with OpenClaw agents.',
481
+ configSchema: {
482
+ type: 'object',
483
+ additionalProperties: true,
484
+ properties: { enabled: { type: 'boolean', default: true } },
485
+ },
486
+ register(api: ClawdbotPluginApi) {
487
+ runtime = api.runtime;
488
+ api.registerChannel({ plugin: localImPlugin });
489
+ api.registerGatewayMethod('nc-local-im-connector.status', async ({ respond }: any) => {
490
+ respond(true, { ok: true });
491
+ });
492
+ api.logger?.info('[nc-LocalIM] 本地通信插件已注册 (支持 Server/Client 双向长连接模式)');
493
+ },
494
+ };
495
+
496
+ export default plugin;