@openclaw-channel/socket-chat 1.0.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/DEVNOTES.md +219 -0
- package/README.md +215 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +44 -0
- package/src/__sdk-stub__.ts +12 -0
- package/src/api.ts +95 -0
- package/src/channel.ts +395 -0
- package/src/config-schema.test.ts +90 -0
- package/src/config-schema.ts +58 -0
- package/src/config.test.ts +318 -0
- package/src/config.ts +218 -0
- package/src/inbound.test.ts +679 -0
- package/src/inbound.ts +344 -0
- package/src/mqtt-client.ts +274 -0
- package/src/outbound.test.ts +176 -0
- package/src/outbound.ts +175 -0
- package/src/probe.ts +49 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +98 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +25 -0
package/DEVNOTES.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# socket-chat 开发经验与注意事项
|
|
2
|
+
|
|
3
|
+
## 1. openclaw plugin-sdk API 签名
|
|
4
|
+
|
|
5
|
+
### status-helpers
|
|
6
|
+
|
|
7
|
+
这三个函数的签名容易踩坑,必须对照源码:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// ✅ 正确
|
|
11
|
+
buildBaseChannelStatusSummary(snapshot)
|
|
12
|
+
// ❌ 错误(没有 options 参数)
|
|
13
|
+
buildBaseChannelStatusSummary({ snapshot, includeMode: false })
|
|
14
|
+
|
|
15
|
+
// ✅ 正确:configured 必须挂在 account 上,不能作为顶层参数
|
|
16
|
+
const accountWithConfigured = { ...account, configured };
|
|
17
|
+
buildBaseAccountStatusSnapshot({ account: accountWithConfigured, runtime, probe })
|
|
18
|
+
|
|
19
|
+
// ✅ 正确:channel 在前,accounts 数组在后
|
|
20
|
+
collectStatusIssuesFromLastError("socket-chat", [snap])
|
|
21
|
+
// ❌ 错误(参数顺序反了)
|
|
22
|
+
collectStatusIssuesFromLastError(snap, "socket-chat")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### ChannelOutboundAdapter
|
|
26
|
+
|
|
27
|
+
feishu / matrix 把 outbound 提取到独立的 `outbound.ts` 文件并导出类型化对象,不要在 `ChannelPlugin` 里内联写匿名对象:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// outbound.ts
|
|
31
|
+
export const socketChatOutbound: ChannelOutboundAdapter = { ... };
|
|
32
|
+
|
|
33
|
+
// channel.ts
|
|
34
|
+
import { socketChatOutbound } from "./outbound.js";
|
|
35
|
+
export const socketChatPlugin: ChannelPlugin = {
|
|
36
|
+
outbound: socketChatOutbound, // 一行引用
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`ChannelPlugin` 没有 `inspectAccount` 字段,不要添加。
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2. Zod v4 的 z.record() 行为变化
|
|
45
|
+
|
|
46
|
+
Zod v4 中 `z.record(Schema)` 把 Schema 当作 **key** 的校验,而不是 value:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// ❌ Zod v4 中 key 校验会失败
|
|
50
|
+
z.record(SocketChatAccountConfigSchema)
|
|
51
|
+
|
|
52
|
+
// ✅ 必须显式传两个参数
|
|
53
|
+
z.record(z.string(), SocketChatAccountConfigSchema)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 3. aedes MQTT broker 的 CJS 兼容
|
|
59
|
+
|
|
60
|
+
aedes 是 CJS 模块,ESM 项目中具名导入会报找不到:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// ❌ 报错:createBroker is not exported
|
|
64
|
+
import { createBroker } from "aedes";
|
|
65
|
+
|
|
66
|
+
// ✅ 用默认导入,再调用方法
|
|
67
|
+
import aedesModule from "aedes";
|
|
68
|
+
const broker = aedesModule.createBroker();
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`@types/aedes` 包不存在,aedes 自带类型定义,直接用即可。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 4. plugin 安装顺序
|
|
76
|
+
|
|
77
|
+
openclaw 启动时会校验 `channels.*` 中的 channel id 是否已注册。**必须先装插件,再写 channel 配置**,否则会报 `unknown channel id` 并阻塞所有 CLI 命令:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
# 正确顺序
|
|
81
|
+
1. 创建 openclaw.plugin.json(id、channels、configSchema)
|
|
82
|
+
2. openclaw plugins install --link <path>
|
|
83
|
+
3. 重启 gateway
|
|
84
|
+
4. 再往 openclaw.json 写 channels.socket-chat 配置
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`openclaw.plugin.json` 是 `plugins install` 的必要条件,缺少会报找不到 manifest。
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 5. default 账号的配置结构
|
|
92
|
+
|
|
93
|
+
socket-chat 的 `default` 账号直接读取 `channels.socket-chat` 顶层字段,**不是** `channels.socket-chat.accounts.default`:
|
|
94
|
+
|
|
95
|
+
```jsonc
|
|
96
|
+
// ✅ 正确:apiKey/apiBaseUrl 放顶层
|
|
97
|
+
"channels": {
|
|
98
|
+
"socket-chat": {
|
|
99
|
+
"apiKey": "mytest-key-001",
|
|
100
|
+
"apiBaseUrl": "http://localhost:3000",
|
|
101
|
+
"dmPolicy": "open",
|
|
102
|
+
"allowFrom": ["*"]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ❌ 错误:放在 accounts.default 下 default 账号读不到
|
|
107
|
+
"channels": {
|
|
108
|
+
"socket-chat": {
|
|
109
|
+
"accounts": {
|
|
110
|
+
"default": { "apiKey": "..." }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 6. vitest 中避免拉取完整 plugin-sdk
|
|
119
|
+
|
|
120
|
+
`openclaw/plugin-sdk` 依赖链包含 `json5` 等在测试环境下无法加载的模块。在 `vitest.config.ts` 中用 alias 指向轻量 stub:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// vitest.config.ts
|
|
124
|
+
resolve: {
|
|
125
|
+
alias: [
|
|
126
|
+
{
|
|
127
|
+
find: "openclaw/plugin-sdk",
|
|
128
|
+
replacement: path.join(dir, "src", "__sdk-stub__.ts"),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
stub 只重新导出测试实际用到的内容:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// __sdk-stub__.ts
|
|
138
|
+
export { resolveAllowlistMatchByCandidates } from "../../openclaw/src/channels/allowlist-match.js";
|
|
139
|
+
export type { ChannelGatewayContext } from "../../openclaw/src/plugin-sdk/index.js";
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 7. 集成测试的隔离策略
|
|
145
|
+
|
|
146
|
+
集成测试用独立端口(`HTTP_PORT=13100`、`MQTT_PORT=11883`)启动 socket-server 子进程,与本地开发端口(3000/1883)完全隔离:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
serverProcess = spawn("npx", ["tsx", serverEntry], {
|
|
150
|
+
env: { ...process.env, HTTP_PORT: "13100", MQTT_PORT: "11883", SEED_API_KEY: "integration-test-key", ... },
|
|
151
|
+
});
|
|
152
|
+
await waitForServer(`http://localhost:13100/health`);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`waitForServer` 轮询 `/health`,避免 race condition。
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 8. dmPolicy 配置
|
|
160
|
+
|
|
161
|
+
| 值 | 行为 |
|
|
162
|
+
|-------------|---------------------------------------|
|
|
163
|
+
| `pairing` | 需先执行 `openclaw channels pair` 配对 |
|
|
164
|
+
| `open` | 任意发送者都能触发 AI |
|
|
165
|
+
| `allowlist` | 只有 `allowFrom` 列表中的 ID 能触发 |
|
|
166
|
+
|
|
167
|
+
本地调试推荐用 `dmPolicy: "open"` + `allowFrom: ["*"]`,省去配对步骤。
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 9. 入站媒体消息处理
|
|
172
|
+
|
|
173
|
+
wechaty-web-panel 在 `publishClawMessage` 中把处理好的媒体数据通过 MQTT 发给插件,payload 携带三个额外字段:
|
|
174
|
+
|
|
175
|
+
| 字段 | 说明 |
|
|
176
|
+
|------|------|
|
|
177
|
+
| `type` | 消息类型字符串:`文字` / `图片` / `视频` / `文件` / `语音` 等 |
|
|
178
|
+
| `url` | 媒体资源链接(已上传 OSS 时为 HTTP URL,未配置 OSS 时为 base64) |
|
|
179
|
+
| `mediaInfo` | 视频号/名片/h5链接等结构化元数据 |
|
|
180
|
+
|
|
181
|
+
### 处理规则(inbound.ts)
|
|
182
|
+
|
|
183
|
+
1. **跳过判断**:`!msg.content?.trim() && !msg.url` — 有 url 时不能跳过(图片消息 content 可能为空)
|
|
184
|
+
2. **base64 过滤**:`msg.url?.startsWith("data:")` 时不注入 `MediaUrl`,避免超长 token 爆炸
|
|
185
|
+
3. **MediaUrl 注入**:仅 HTTP URL 时写入 `MediaUrl`/`MediaUrls`/`MediaPath`/`MediaType`
|
|
186
|
+
4. **body 优先级**:`content`(已格式化的描述文字) > `<media:${type}>` placeholder
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const mediaUrl = msg.url && !msg.url.startsWith("data:") ? msg.url : undefined;
|
|
190
|
+
const body = msg.content?.trim() || (isMediaMsg ? `<media:${msg.type}>` : "");
|
|
191
|
+
|
|
192
|
+
...(mediaUrl ? {
|
|
193
|
+
MediaUrl: mediaUrl,
|
|
194
|
+
MediaUrls: [mediaUrl],
|
|
195
|
+
MediaPath: mediaUrl,
|
|
196
|
+
MediaType: msg.type === "图片" ? "image/jpeg" : msg.type === "视频" ? "video/mp4" : undefined,
|
|
197
|
+
} : {}),
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 场景对照
|
|
201
|
+
|
|
202
|
+
| 场景 | `content` | `url` | AI 收到 |
|
|
203
|
+
|------|-----------|-------|---------|
|
|
204
|
+
| 纯文字 | 消息文本 | — | 消息文本 |
|
|
205
|
+
| 图片(有 OSS) | 包含链接的描述 | HTTP URL | 描述文字 + `MediaUrl` |
|
|
206
|
+
| 图片(无 OSS) | 含 base64 的描述 | `data:image/...` | 仅描述文字,base64 被过滤 |
|
|
207
|
+
| 空 content + URL | — | HTTP URL | `<media:图片>` placeholder |
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 10. 测试工具链
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
socket-server/ — npm run dev 自动加载 .env,预置测试账号
|
|
215
|
+
socket-client/ — npm start 交互式终端,输入消息→看 AI 回复
|
|
216
|
+
socket-chat/ — npx vitest run src/integration.test.ts
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
三个目录各自有 `.env`(被 `.gitignore` 忽略),`.env.example` 作为模板提交到 git。
|
package/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# socket-chat OpenClaw Extension
|
|
2
|
+
|
|
3
|
+
通过 **MQTT** 协议与自定义 IM 系统对接的 OpenClaw channel plugin。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 工作原理
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
IM 平台 ──MQTT──► reciveTopic ──► socket-chat plugin ──► OpenClaw AI
|
|
11
|
+
OpenClaw AI ──► socket-chat plugin ──MQTT──► sendTopic ──► IM 平台
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
1. 插件启动时,调用 `GET /api/openclaw/chat/config?apikey={apiKey}` 获取 MQTT 连接参数
|
|
15
|
+
2. 建立 MQTT 连接,订阅 `reciveTopic`
|
|
16
|
+
3. 收到消息后,通过 `channelRuntime` 派发给 AI agent
|
|
17
|
+
4. AI 回复通过 `outbound.sendText/sendMedia` 向 `sendTopic` 发布
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# 从 npm 安装
|
|
25
|
+
openclaw plugins add @openclaw-channel/socket-chat
|
|
26
|
+
|
|
27
|
+
# 或者本地开发时从路径安装
|
|
28
|
+
openclaw plugins add /path/to/socket-chat
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 配置
|
|
34
|
+
|
|
35
|
+
在 `~/.openclaw/config.yaml` 中添加:
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
channels:
|
|
39
|
+
socket-chat:
|
|
40
|
+
apiKey: "your-api-key"
|
|
41
|
+
enabled: true
|
|
42
|
+
dmPolicy: "pairing" # pairing | open | allowlist
|
|
43
|
+
allowFrom: [] # 允许触发 AI 的发送者 ID 白名单
|
|
44
|
+
requireMention: true # 群组消息是否需要 @提及机器人
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 多账号配置
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
channels:
|
|
51
|
+
socket-chat:
|
|
52
|
+
accounts:
|
|
53
|
+
work:
|
|
54
|
+
apiKey: "api-key"
|
|
55
|
+
enabled: true
|
|
56
|
+
personal:
|
|
57
|
+
apiKey: "api-key"
|
|
58
|
+
enabled: true
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 可选高级配置
|
|
62
|
+
|
|
63
|
+
| 字段 | 默认值 | 说明 |
|
|
64
|
+
|------|--------|------|
|
|
65
|
+
| `mqttConfigTtlSec` | `300` | MQTT 配置缓存时间(秒) |
|
|
66
|
+
| `maxReconnectAttempts` | `10` | MQTT 断线最大重连次数 |
|
|
67
|
+
| `reconnectBaseDelayMs` | `2000` | 重连基础延迟(毫秒,指数退避) |
|
|
68
|
+
| `useTls` | `false` | 是否使用 `mqtts://`(TLS) |
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 通过 CLI 添加账号
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
openclaw channels add socket-chat --token <apiKey>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 消息格式
|
|
81
|
+
|
|
82
|
+
### 收到的 MQTT 消息(reciveTopic)
|
|
83
|
+
|
|
84
|
+
文字消息:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"content": "消息内容",
|
|
89
|
+
"robotId": "wxid_robot",
|
|
90
|
+
"senderId": "wxid_user123",
|
|
91
|
+
"senderName": "用户昵称",
|
|
92
|
+
"isGroup": false,
|
|
93
|
+
"groupId": "roomid_xxx",
|
|
94
|
+
"groupName": "群组名称",
|
|
95
|
+
"timestamp": 1234567890123,
|
|
96
|
+
"messageId": "uuid-xxx",
|
|
97
|
+
"type": "文字"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
图片 / 视频 / 文件消息(已上传 OSS):
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"content": "【图片消息】\n文件名:img.jpg\n下载链接:https://oss.example.com/img.jpg",
|
|
106
|
+
"robotId": "wxid_robot",
|
|
107
|
+
"senderId": "wxid_user123",
|
|
108
|
+
"senderName": "用户昵称",
|
|
109
|
+
"isGroup": false,
|
|
110
|
+
"timestamp": 1234567890123,
|
|
111
|
+
"messageId": "uuid-xxx",
|
|
112
|
+
"type": "图片",
|
|
113
|
+
"url": "https://oss.example.com/img.jpg"
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
> **说明**:`type` 枚举值包括 `文字`、`图片`、`视频`、`文件`、`语音`、`名片`、`h5链接`、`视频号`、`位置`、`历史记录`。
|
|
118
|
+
>
|
|
119
|
+
> 图片/视频等媒体消息会同时携带 `content`(格式化描述文字)和 `url`(资源链接)。若平台未配置 OSS,`url` 为 base64 字符串,插件会忽略 base64 内容,仅将 `content` 描述文字传给 AI agent。
|
|
120
|
+
|
|
121
|
+
#### 媒体消息处理逻辑
|
|
122
|
+
|
|
123
|
+
| 场景 | `content` | `url` | AI 收到的内容 |
|
|
124
|
+
|------|-----------|-------|--------------|
|
|
125
|
+
| 纯文字 | 消息文本 | — | 消息文本 |
|
|
126
|
+
| 图片(有 OSS URL) | 包含下载链接的描述 | HTTP URL | 描述文字 + `MediaUrl` 字段 |
|
|
127
|
+
| 图片(无 OSS,base64) | 包含 base64 的描述 | `data:image/...` | 仅描述文字(base64 被过滤) |
|
|
128
|
+
| 仅有 URL、无 content | — | HTTP URL | `<media:图片>` placeholder |
|
|
129
|
+
|
|
130
|
+
### 发送的 MQTT 消息(sendTopic)
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"isGroup": false,
|
|
135
|
+
"contactId": "wxid_user123",
|
|
136
|
+
"messages": [
|
|
137
|
+
{ "type": 1, "content": "文字消息" }
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
群消息 + @提及:
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"isGroup": true,
|
|
146
|
+
"groupId": "roomid_xxx",
|
|
147
|
+
"mentionIds": ["wxid_a", "wxid_b"],
|
|
148
|
+
"messages": [
|
|
149
|
+
{ "type": 1, "content": "回复内容" },
|
|
150
|
+
{ "type": 2, "url": "https://example.com/image.jpg" }
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 发消息目标格式(openclaw message send)
|
|
158
|
+
|
|
159
|
+
| 格式 | 说明 |
|
|
160
|
+
|------|------|
|
|
161
|
+
| `wxid_xxx` | 私聊某用户 |
|
|
162
|
+
| `group:roomid_xxx` | 发到群组 |
|
|
163
|
+
| `group:roomid_xxx@wxid_a,wxid_b` | 发到群组并 @提及用户 |
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
openclaw message send "Hello" --channel socket-chat --to wxid_user123
|
|
167
|
+
openclaw message send "Hello group" --channel socket-chat --to group:roomid_xxx
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 项目结构
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
socket-chat/
|
|
176
|
+
├── index.ts # 插件入口(register)
|
|
177
|
+
├── package.json # 包配置,含 openclaw.extensions
|
|
178
|
+
├── tsconfig.json
|
|
179
|
+
└── src/
|
|
180
|
+
├── types.ts # MQTT 消息类型定义
|
|
181
|
+
├── config-schema.ts # Zod 配置 Schema
|
|
182
|
+
├── config.ts # 账号配置解析 + 写入工具
|
|
183
|
+
├── api.ts # GET /api/openclaw/chat/config 调用(带缓存)
|
|
184
|
+
├── outbound.ts # 发送消息(buildPayload + sendSocketChatMessage)
|
|
185
|
+
├── inbound.ts # 入站消息处理 → dispatch AI 回复
|
|
186
|
+
├── mqtt-client.ts # MQTT 连接管理 + 自动重连 monitor 循环
|
|
187
|
+
├── probe.ts # 账号连通性探测
|
|
188
|
+
├── runtime.ts # PluginRuntime 单例
|
|
189
|
+
└── channel.ts # ChannelPlugin 主体(所有 adapter 实现)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## API 接口要求
|
|
195
|
+
|
|
196
|
+
后端需实现:
|
|
197
|
+
|
|
198
|
+
### `GET /openapi/v1/openclaw/chat/config?apikey={apiKey}`
|
|
199
|
+
|
|
200
|
+
**Response 200:**
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"host": "mqtt.example.com",
|
|
204
|
+
"port": "1883",
|
|
205
|
+
"username": "mqttuser",
|
|
206
|
+
"password": "mqttpass",
|
|
207
|
+
"clientId": "openclaw-bot-001",
|
|
208
|
+
"reciveTopic": "/im/botId/msg",
|
|
209
|
+
"sendTopic": "/im/botId/send",
|
|
210
|
+
"robotId": "wxid_bot",
|
|
211
|
+
"userId": "user123"
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
所有字段均为必填字符串。
|
package/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
import { socketChatPlugin } from "./src/channel.js";
|
|
4
|
+
import { setSocketChatRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: "socket-chat",
|
|
8
|
+
name: "Socket Chat",
|
|
9
|
+
description: "Socket Chat channel plugin — MQTT-based IM bridge",
|
|
10
|
+
configSchema: emptyPluginConfigSchema(),
|
|
11
|
+
register(api: OpenClawPluginApi) {
|
|
12
|
+
setSocketChatRuntime(api.runtime);
|
|
13
|
+
api.registerChannel({ plugin: socketChatPlugin });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openclaw-channel/socket-chat",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenClaw Socket Chat channel plugin — MQTT-based IM bridge",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"mqtt": "^4.3.8",
|
|
15
|
+
"zod": "^4.3.6"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"typescript": "^5.7.3",
|
|
20
|
+
"vitest": "^2.0.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"openclaw": "*"
|
|
24
|
+
},
|
|
25
|
+
"openclaw": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./index.ts"
|
|
28
|
+
],
|
|
29
|
+
"channel": {
|
|
30
|
+
"id": "socket-chat",
|
|
31
|
+
"label": "Socket Chat",
|
|
32
|
+
"selectionLabel": "Socket Chat (MQTT plugin)",
|
|
33
|
+
"docsPath": "/channels/socket-chat",
|
|
34
|
+
"docsLabel": "socket-chat",
|
|
35
|
+
"blurb": "MQTT-based IM bridge; configure an API key to connect.",
|
|
36
|
+
"order": 90
|
|
37
|
+
},
|
|
38
|
+
"install": {
|
|
39
|
+
"npmSpec": "@openclaw/socket-chat",
|
|
40
|
+
"localPath": ".",
|
|
41
|
+
"defaultChoice": "local"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal openclaw/plugin-sdk stub for socket-chat unit tests.
|
|
3
|
+
*
|
|
4
|
+
* Only re-exports the symbols that socket-chat/src actually imports at runtime.
|
|
5
|
+
* This avoids pulling in the full SDK dependency tree (which includes modules
|
|
6
|
+
* like json5 that require the openclaw monorepo workspace to resolve).
|
|
7
|
+
*/
|
|
8
|
+
export { resolveAllowlistMatchByCandidates } from "../../openclaw/src/channels/allowlist-match.js";
|
|
9
|
+
export { createNormalizedOutboundDeliverer } from "../../openclaw/src/plugin-sdk/reply-payload.js";
|
|
10
|
+
|
|
11
|
+
// ---- type-only re-exports (erased at runtime) ----
|
|
12
|
+
export type { ChannelGatewayContext } from "../../openclaw/src/plugin-sdk/index.js";
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { SocketChatMqttConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 调用后端 API 获取 MQTT 连接配置
|
|
5
|
+
* GET {apiBaseUrl}/api/openclaw/chat/config?apikey={apiKey}
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchMqttConfig(params: {
|
|
8
|
+
apiBaseUrl: string;
|
|
9
|
+
apiKey: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}): Promise<SocketChatMqttConfig> {
|
|
12
|
+
const { apiBaseUrl, apiKey, timeoutMs = 10_000 } = params;
|
|
13
|
+
const url = `${apiBaseUrl.replace(/\/$/, "")}/openapi/v1/openclaw/chat/config?apiKey=${encodeURIComponent(apiKey)}`;
|
|
14
|
+
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
method: "GET",
|
|
21
|
+
headers: { Accept: "application/json" },
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const body = await res.text().catch(() => "");
|
|
27
|
+
throw new Error(
|
|
28
|
+
`fetchMqttConfig: HTTP ${res.status} from ${url}${body ? ` — ${body.slice(0, 200)}` : ""}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const data = (await res.json()) as unknown;
|
|
33
|
+
validateMqttConfig(data);
|
|
34
|
+
return data as SocketChatMqttConfig;
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateMqttConfig(data: unknown): asserts data is SocketChatMqttConfig {
|
|
41
|
+
if (!data || typeof data !== "object") {
|
|
42
|
+
throw new Error("fetchMqttConfig: response is not an object");
|
|
43
|
+
}
|
|
44
|
+
const d = data as Record<string, unknown>;
|
|
45
|
+
const required: (keyof SocketChatMqttConfig)[] = [
|
|
46
|
+
"host",
|
|
47
|
+
"port",
|
|
48
|
+
"username",
|
|
49
|
+
"password",
|
|
50
|
+
"clientId",
|
|
51
|
+
"reciveTopic",
|
|
52
|
+
"sendTopic",
|
|
53
|
+
];
|
|
54
|
+
for (const key of required) {
|
|
55
|
+
if (!d[key]) {
|
|
56
|
+
throw new Error(`fetchMqttConfig: missing or invalid field "${key}" in response`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 简单的 TTL 缓存,避免每次重连都重新请求 config
|
|
63
|
+
*/
|
|
64
|
+
type CacheEntry = {
|
|
65
|
+
config: SocketChatMqttConfig;
|
|
66
|
+
fetchedAt: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const configCache = new Map<string, CacheEntry>();
|
|
70
|
+
|
|
71
|
+
export async function fetchMqttConfigCached(params: {
|
|
72
|
+
apiBaseUrl: string;
|
|
73
|
+
apiKey: string;
|
|
74
|
+
ttlMs?: number;
|
|
75
|
+
timeoutMs?: number;
|
|
76
|
+
}): Promise<SocketChatMqttConfig> {
|
|
77
|
+
const { apiBaseUrl, apiKey, ttlMs = 300_000, timeoutMs } = params;
|
|
78
|
+
const cacheKey = `${apiBaseUrl}::${apiKey}`;
|
|
79
|
+
const cached = configCache.get(cacheKey);
|
|
80
|
+
if (cached && Date.now() - cached.fetchedAt < ttlMs) {
|
|
81
|
+
return cached.config;
|
|
82
|
+
}
|
|
83
|
+
const config = await fetchMqttConfig({ apiBaseUrl, apiKey, timeoutMs });
|
|
84
|
+
configCache.set(cacheKey, { config, fetchedAt: Date.now() });
|
|
85
|
+
return config;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 清除指定账号的缓存(例如重连时强制刷新) */
|
|
89
|
+
export function invalidateMqttConfigCache(params: {
|
|
90
|
+
apiBaseUrl: string;
|
|
91
|
+
apiKey: string;
|
|
92
|
+
}): void {
|
|
93
|
+
const cacheKey = `${params.apiBaseUrl}::${params.apiKey}`;
|
|
94
|
+
configCache.delete(cacheKey);
|
|
95
|
+
}
|