@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 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
- > **说明**:`type` 枚举值包括 `文字`、`图片`、`视频`、`文件`、`语音`、`名片`、`h5链接`、`视频号`、`位置`、`历史记录`。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-channel/socket-chat",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "OpenClaw Socket Chat channel plugin — MQTT-based IM bridge",
5
5
  "type": "module",
6
6
  "publishConfig": {
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: `mqtt://${(probe as { host?: string }).host}:${(probe as { port?: string }).port}` }
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,
@@ -15,7 +15,6 @@ describe("SocketChatAccountConfigSchema", () => {
15
15
  mqttConfigTtlSec: 600,
16
16
  maxReconnectAttempts: 5,
17
17
  reconnectBaseDelayMs: 1000,
18
- useTls: false,
19
18
  });
20
19
 
21
20
  expect(parsed.apiKey).toBe("key123");
@@ -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>;
@@ -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
- // 检测消息中是否包含 robotId(简单 @提及)
139
+ // 优先使用平台传来的精确判断(on-claw-message.js 已计算好 isMention)
140
+ // fallback:检查消息内容中是否包含 @robotId(不做宽泛的 content.includes(robotId) 以避免误判)
140
141
  const mentioned =
141
- msg.content.includes(`@${robotId}`) ||
142
- msg.content.includes(robotId);
142
+ msg.isGroupMention === true ||
143
+ msg.content.includes(`@${robotId}`);
143
144
 
144
145
  if (!mentioned) {
145
146
  log.debug?.(
@@ -46,9 +46,8 @@ export function clearActiveMqttSession(accountId: string): void {
46
46
  // 工具函数
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
- function buildMqttUrl(mqttConfig: SocketChatMqttConfig, useTls: boolean): string {
50
- const protocol = useTls ? "mqtts" : "mqtt";
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, useTls);
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
@@ -18,6 +18,8 @@ export type SocketChatInboundMessage = {
18
18
  groupId?: string;
19
19
  /** 群名称(isGroup=true 时存在) */
20
20
  groupName?: string;
21
+ /** 是否在群中 @了机器人(由平台精确计算,优先于文本匹配) */
22
+ isGroupMention?: boolean;
21
23
  /** 11 位时间戳(毫秒) */
22
24
  timestamp: number;
23
25
  /** 消息唯一 ID */
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。