@openclaw-channel/socket-chat 1.0.0 → 1.0.1
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 +110 -6
- package/package.json +1 -1
- package/src/channel.ts +1 -4
- package/src/config-schema.test.ts +0 -1
- package/src/config-schema.ts +0 -2
- package/src/inbound.test.ts +60 -0
- package/src/inbound.ts +4 -3
- package/src/mqtt-client.ts +3 -5
- package/src/types.ts +2 -0
- package/DEVNOTES.md +0 -219
package/README.md
CHANGED
|
@@ -65,7 +65,6 @@ channels:
|
|
|
65
65
|
| `mqttConfigTtlSec` | `300` | MQTT 配置缓存时间(秒) |
|
|
66
66
|
| `maxReconnectAttempts` | `10` | MQTT 断线最大重连次数 |
|
|
67
67
|
| `reconnectBaseDelayMs` | `2000` | 重连基础延迟(毫秒,指数退避) |
|
|
68
|
-
| `useTls` | `false` | 是否使用 `mqtts://`(TLS) |
|
|
69
68
|
|
|
70
69
|
---
|
|
71
70
|
|
|
@@ -81,7 +80,32 @@ openclaw channels add socket-chat --token <apiKey>
|
|
|
81
80
|
|
|
82
81
|
### 收到的 MQTT 消息(reciveTopic)
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
所有消息类型共有的字段:
|
|
84
|
+
|
|
85
|
+
| 字段 | 类型 | 说明 |
|
|
86
|
+
|------|------|------|
|
|
87
|
+
| `content` | string | 消息内容(文字消息为原文,媒体消息为格式化描述) |
|
|
88
|
+
| `robotId` | string | 机器人微信 ID |
|
|
89
|
+
| `senderId` | string | 发消息人微信 ID(wxid) |
|
|
90
|
+
| `senderName` | string | 发消息人昵称 |
|
|
91
|
+
| `isGroup` | boolean | 是否为群消息 |
|
|
92
|
+
| `groupId` | string \| undefined | 群 ID(仅群消息) |
|
|
93
|
+
| `groupName` | string \| undefined | 群名称(仅群消息) |
|
|
94
|
+
| `isGroupMention` | boolean | 是否在群中 @了机器人 |
|
|
95
|
+
| `timestamp` | number | 消息时间戳(13 位毫秒) |
|
|
96
|
+
| `messageId` | string | 消息 ID |
|
|
97
|
+
| `type` | string | 消息类型,见下方枚举 |
|
|
98
|
+
| `conversionId` | string | 会话 ID(私聊为 senderId,群聊为 groupId) |
|
|
99
|
+
| `conversionName` | string | 会话名称(私聊为昵称,群聊为群名) |
|
|
100
|
+
| `chatAlias` | string \| undefined | 发消息人在机器人通讯录中的备注 |
|
|
101
|
+
| `chatUserWeixin` | string | 发消息人的微信号(weixin 字段,可能为空) |
|
|
102
|
+
| `isMyself` | boolean | 是否为机器人自己发的消息 |
|
|
103
|
+
| `url` | string \| undefined | 媒体资源链接(OSS URL 或 base64,仅媒体消息携带) |
|
|
104
|
+
| `mediaInfo` | object \| undefined | 结构化媒体信息(名片、视频号、h5 链接携带) |
|
|
105
|
+
|
|
106
|
+
`type` 枚举值:`文字`、`图片`、`视频`、`文件`、`语音`、`名片`、`h5链接`、`视频号`、`位置`、`历史记录`。
|
|
107
|
+
|
|
108
|
+
文字消息示例:
|
|
85
109
|
|
|
86
110
|
```json
|
|
87
111
|
{
|
|
@@ -90,11 +114,38 @@ openclaw channels add socket-chat --token <apiKey>
|
|
|
90
114
|
"senderId": "wxid_user123",
|
|
91
115
|
"senderName": "用户昵称",
|
|
92
116
|
"isGroup": false,
|
|
117
|
+
"isGroupMention": false,
|
|
118
|
+
"timestamp": 1234567890123,
|
|
119
|
+
"messageId": "uuid-xxx",
|
|
120
|
+
"type": "文字",
|
|
121
|
+
"conversionId": "wxid_user123",
|
|
122
|
+
"conversionName": "用户昵称",
|
|
123
|
+
"chatAlias": "同事小明",
|
|
124
|
+
"chatUserWeixin": "",
|
|
125
|
+
"isMyself": false
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
群消息(@提及)示例:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"content": "消息内容",
|
|
134
|
+
"robotId": "wxid_robot",
|
|
135
|
+
"senderId": "wxid_user123",
|
|
136
|
+
"senderName": "用户昵称",
|
|
137
|
+
"isGroup": true,
|
|
93
138
|
"groupId": "roomid_xxx",
|
|
94
|
-
"groupName": "
|
|
139
|
+
"groupName": "工作群",
|
|
140
|
+
"isGroupMention": true,
|
|
95
141
|
"timestamp": 1234567890123,
|
|
96
142
|
"messageId": "uuid-xxx",
|
|
97
|
-
"type": "文字"
|
|
143
|
+
"type": "文字",
|
|
144
|
+
"conversionId": "roomid_xxx",
|
|
145
|
+
"conversionName": "工作群",
|
|
146
|
+
"chatAlias": "",
|
|
147
|
+
"chatUserWeixin": "",
|
|
148
|
+
"isMyself": false
|
|
98
149
|
}
|
|
99
150
|
```
|
|
100
151
|
|
|
@@ -107,15 +158,66 @@ openclaw channels add socket-chat --token <apiKey>
|
|
|
107
158
|
"senderId": "wxid_user123",
|
|
108
159
|
"senderName": "用户昵称",
|
|
109
160
|
"isGroup": false,
|
|
161
|
+
"isGroupMention": false,
|
|
110
162
|
"timestamp": 1234567890123,
|
|
111
163
|
"messageId": "uuid-xxx",
|
|
112
164
|
"type": "图片",
|
|
165
|
+
"conversionId": "wxid_user123",
|
|
166
|
+
"conversionName": "用户昵称",
|
|
167
|
+
"chatAlias": "",
|
|
168
|
+
"chatUserWeixin": "",
|
|
169
|
+
"isMyself": false,
|
|
113
170
|
"url": "https://oss.example.com/img.jpg"
|
|
114
171
|
}
|
|
115
172
|
```
|
|
116
173
|
|
|
117
|
-
|
|
118
|
-
|
|
174
|
+
名片消息(携带 `mediaInfo`):
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"content": "【名片消息】\n联系人昵称:张三\n联系人ID:wxid_zhangsan",
|
|
179
|
+
"type": "名片",
|
|
180
|
+
"mediaInfo": {
|
|
181
|
+
"name": "张三",
|
|
182
|
+
"avatar": "https://...",
|
|
183
|
+
"wxid": "wxid_zhangsan"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
视频号消息(携带 `mediaInfo`):
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"content": "【视频号消息】\n视频号昵称:xxx\n视频号简介:...\n视频号链接:https://...",
|
|
193
|
+
"type": "视频号",
|
|
194
|
+
"mediaInfo": {
|
|
195
|
+
"nickname": "xxx",
|
|
196
|
+
"coverUrl": "https://...",
|
|
197
|
+
"avatar": "https://...",
|
|
198
|
+
"desc": "视频号简介",
|
|
199
|
+
"url": "https://...",
|
|
200
|
+
"objectId": "...",
|
|
201
|
+
"objectNonceId": "..."
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
h5 链接消息(携带 `mediaInfo`):
|
|
207
|
+
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"content": "【链接消息】\n链接标题:xxx\n链接描述:...\n链接地址:https://...",
|
|
211
|
+
"type": "h5链接",
|
|
212
|
+
"mediaInfo": {
|
|
213
|
+
"url": "https://...",
|
|
214
|
+
"description": "链接描述",
|
|
215
|
+
"imageUrl": "https://thumbnail...",
|
|
216
|
+
"title": "链接标题"
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
119
221
|
> 图片/视频等媒体消息会同时携带 `content`(格式化描述文字)和 `url`(资源链接)。若平台未配置 OSS,`url` 为 base64 字符串,插件会忽略 base64 内容,仅将 `content` 描述文字传给 AI agent。
|
|
120
222
|
|
|
121
223
|
#### 媒体消息处理逻辑
|
|
@@ -127,6 +229,8 @@ openclaw channels add socket-chat --token <apiKey>
|
|
|
127
229
|
| 图片(无 OSS,base64) | 包含 base64 的描述 | `data:image/...` | 仅描述文字(base64 被过滤) |
|
|
128
230
|
| 仅有 URL、无 content | — | HTTP URL | `<media:图片>` placeholder |
|
|
129
231
|
|
|
232
|
+
---
|
|
233
|
+
|
|
130
234
|
### 发送的 MQTT 消息(sendTopic)
|
|
131
235
|
|
|
132
236
|
```json
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -88,7 +88,6 @@ export const socketChatPlugin: ChannelPlugin<ResolvedSocketChatAccount> = {
|
|
|
88
88
|
mqttConfigTtlSec: { type: "number" },
|
|
89
89
|
maxReconnectAttempts: { type: "number" },
|
|
90
90
|
reconnectBaseDelayMs: { type: "number" },
|
|
91
|
-
useTls: { type: "boolean" },
|
|
92
91
|
accounts: {
|
|
93
92
|
type: "object",
|
|
94
93
|
additionalProperties: {
|
|
@@ -105,7 +104,6 @@ export const socketChatPlugin: ChannelPlugin<ResolvedSocketChatAccount> = {
|
|
|
105
104
|
mqttConfigTtlSec: { type: "number" },
|
|
106
105
|
maxReconnectAttempts: { type: "number" },
|
|
107
106
|
reconnectBaseDelayMs: { type: "number" },
|
|
108
|
-
useTls: { type: "boolean" },
|
|
109
107
|
},
|
|
110
108
|
},
|
|
111
109
|
},
|
|
@@ -117,7 +115,6 @@ export const socketChatPlugin: ChannelPlugin<ResolvedSocketChatAccount> = {
|
|
|
117
115
|
dmPolicy: { label: "私信策略", help: "pairing=需配对, open=任意人, allowlist=白名单" },
|
|
118
116
|
allowFrom: { label: "允许来源", help: "允许触发 AI 的发送者 ID 列表" },
|
|
119
117
|
requireMention: { label: "群消息需@提及", help: "群组消息是否必须@提及机器人才触发" },
|
|
120
|
-
useTls: { label: "使用 TLS(mqtts://)", advanced: true },
|
|
121
118
|
mqttConfigTtlSec: { label: "MQTT 配置缓存时间(秒)", advanced: true },
|
|
122
119
|
maxReconnectAttempts: { label: "最大重连次数", advanced: true },
|
|
123
120
|
reconnectBaseDelayMs: { label: "重连基础延迟(毫秒)", advanced: true },
|
|
@@ -305,7 +302,7 @@ export const socketChatPlugin: ChannelPlugin<ResolvedSocketChatAccount> = {
|
|
|
305
302
|
// 补充 probe 信息到 snapshot
|
|
306
303
|
probe,
|
|
307
304
|
...(probe && typeof probe === "object" && "host" in probe
|
|
308
|
-
? { baseUrl:
|
|
305
|
+
? { baseUrl: `${(probe as { host?: string }).host}:${(probe as { port?: string }).port}` }
|
|
309
306
|
: {}),
|
|
310
307
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
311
308
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
package/src/config-schema.ts
CHANGED
|
@@ -44,8 +44,6 @@ export const SocketChatAccountConfigSchema = z.object({
|
|
|
44
44
|
maxReconnectAttempts: z.number().optional(),
|
|
45
45
|
/** MQTT 重连基础延迟(毫秒),默认 2000,指数退避 */
|
|
46
46
|
reconnectBaseDelayMs: z.number().optional(),
|
|
47
|
-
/** MQTT over TLS,默认 false */
|
|
48
|
-
useTls: z.boolean().optional(),
|
|
49
47
|
});
|
|
50
48
|
|
|
51
49
|
export type SocketChatAccountConfig = z.infer<typeof SocketChatAccountConfigSchema>;
|
package/src/inbound.test.ts
CHANGED
|
@@ -321,6 +321,66 @@ describe("handleInboundMessage — dmPolicy=allowlist", () => {
|
|
|
321
321
|
// ---------------------------------------------------------------------------
|
|
322
322
|
|
|
323
323
|
describe("handleInboundMessage — group messages", () => {
|
|
324
|
+
it("dispatches AI reply for group message with isGroupMention=true (no @text needed)", async () => {
|
|
325
|
+
const runtime = makeMockRuntime();
|
|
326
|
+
|
|
327
|
+
const ctx = makeCtx(runtime, {
|
|
328
|
+
channels: {
|
|
329
|
+
"socket-chat": {
|
|
330
|
+
apiKey: "k",
|
|
331
|
+
apiBaseUrl: "https://x.com",
|
|
332
|
+
requireMention: true,
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await handleInboundMessage({
|
|
338
|
+
msg: makeMsg({
|
|
339
|
+
isGroup: true,
|
|
340
|
+
groupId: "roomid_group1",
|
|
341
|
+
robotId: "robot_abc",
|
|
342
|
+
isGroupMention: true,
|
|
343
|
+
content: "hello group (no @text in content)",
|
|
344
|
+
}),
|
|
345
|
+
accountId: "default",
|
|
346
|
+
ctx: ctx as never,
|
|
347
|
+
log: ctx.log,
|
|
348
|
+
sendReply: vi.fn(async () => {}),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("skips group message when isGroupMention=false and no @text", async () => {
|
|
355
|
+
const runtime = makeMockRuntime();
|
|
356
|
+
|
|
357
|
+
const ctx = makeCtx(runtime, {
|
|
358
|
+
channels: {
|
|
359
|
+
"socket-chat": {
|
|
360
|
+
apiKey: "k",
|
|
361
|
+
apiBaseUrl: "https://x.com",
|
|
362
|
+
requireMention: true,
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await handleInboundMessage({
|
|
368
|
+
msg: makeMsg({
|
|
369
|
+
isGroup: true,
|
|
370
|
+
groupId: "roomid_group1",
|
|
371
|
+
robotId: "robot_abc",
|
|
372
|
+
isGroupMention: false,
|
|
373
|
+
content: "just chatting",
|
|
374
|
+
}),
|
|
375
|
+
accountId: "default",
|
|
376
|
+
ctx: ctx as never,
|
|
377
|
+
log: ctx.log,
|
|
378
|
+
sendReply: vi.fn(async () => {}),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
324
384
|
it("dispatches AI reply for group message mentioning robotId", async () => {
|
|
325
385
|
const runtime = makeMockRuntime();
|
|
326
386
|
|
package/src/inbound.ts
CHANGED
|
@@ -136,10 +136,11 @@ function checkGroupMention(params: {
|
|
|
136
136
|
const requireMention = account.config.requireMention !== false; // 默认 true
|
|
137
137
|
if (!requireMention) return true;
|
|
138
138
|
|
|
139
|
-
//
|
|
139
|
+
// 优先使用平台传来的精确判断(on-claw-message.js 已计算好 isMention)
|
|
140
|
+
// fallback:检查消息内容中是否包含 @robotId(不做宽泛的 content.includes(robotId) 以避免误判)
|
|
140
141
|
const mentioned =
|
|
141
|
-
msg.
|
|
142
|
-
msg.content.includes(robotId);
|
|
142
|
+
msg.isGroupMention === true ||
|
|
143
|
+
msg.content.includes(`@${robotId}`);
|
|
143
144
|
|
|
144
145
|
if (!mentioned) {
|
|
145
146
|
log.debug?.(
|
package/src/mqtt-client.ts
CHANGED
|
@@ -46,9 +46,8 @@ export function clearActiveMqttSession(accountId: string): void {
|
|
|
46
46
|
// 工具函数
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
48
|
|
|
49
|
-
function buildMqttUrl(mqttConfig: SocketChatMqttConfig
|
|
50
|
-
|
|
51
|
-
return `${protocol}://${mqttConfig.host}:${mqttConfig.port}`;
|
|
49
|
+
function buildMqttUrl(mqttConfig: SocketChatMqttConfig): string {
|
|
50
|
+
return `${mqttConfig.host}:${mqttConfig.port}`;
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
function parseInboundMessage(raw: Buffer | string): SocketChatInboundMessage | null {
|
|
@@ -117,7 +116,6 @@ export async function monitorSocketChatProviderWithRegistry(params: {
|
|
|
117
116
|
const { abortSignal } = ctx;
|
|
118
117
|
const maxReconnects = account.config.maxReconnectAttempts ?? MAX_RECONNECT_ATTEMPTS_DEFAULT;
|
|
119
118
|
const reconnectBaseMs = account.config.reconnectBaseDelayMs ?? RECONNECT_BASE_DELAY_MS_DEFAULT;
|
|
120
|
-
const useTls = account.config.useTls ?? false;
|
|
121
119
|
const mqttConfigTtlMs = (account.config.mqttConfigTtlSec ?? 300) * 1000;
|
|
122
120
|
|
|
123
121
|
let reconnectAttempts = 0;
|
|
@@ -155,7 +153,7 @@ export async function monitorSocketChatProviderWithRegistry(params: {
|
|
|
155
153
|
}
|
|
156
154
|
|
|
157
155
|
// 2. 建立 MQTT 连接
|
|
158
|
-
const mqttUrl = buildMqttUrl(mqttConfig
|
|
156
|
+
const mqttUrl = buildMqttUrl(mqttConfig);
|
|
159
157
|
log.info(`[${accountId}] connecting to ${mqttUrl} (clientId=${mqttConfig.clientId})`);
|
|
160
158
|
|
|
161
159
|
const client = mqtt.connect(mqttUrl, {
|
package/src/types.ts
CHANGED
package/DEVNOTES.md
DELETED
|
@@ -1,219 +0,0 @@
|
|
|
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。
|