@youdu/openclaw-youdu 2026.9.15 → 2026.10.9

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
@@ -2,9 +2,30 @@
2
2
 
3
3
  有度机器人接入插件,基于 WebSocket 长连接实现消息收发。
4
4
 
5
- 知识库 Tool 需要 OpenClaw `2026.7.1-2` 或更高版本;旧版本不提供本插件所需的 Tool hook 参数调整能力。可信有度 IM 上下文由插件按 OpenClaw `runId` 短期保存,并在本轮 dispatch 结束时清理;插件不会在注册结束后调用注册期 API。
5
+ ## 版本要求
6
6
 
7
- 安全边界:知识库调用的短期 `invocationToken` 只由插件 hook 注入并一次性消费,不进入 CLI 请求、stdin、Tool 返回值或插件日志。OpenClaw `2026.7.1-2` 会在进程内保存调整后的参数,并将其提供给 `after_tool_call`;同时完整调整参数可能出现在宿主受控的 private diagnostic payload 和 loop-tracking 内存中。该 token 不写入模型会话 transcript,也不作为模型工具 schema 字段。宿主诊断的可见范围由 OpenClaw 控制,部署时应按受控诊断数据处理。
7
+ | 项 | 要求 | 说明 |
8
+ | --- | --- | --- |
9
+ | OpenClaw | **>= 2026.7.1-2** | 与 `peerDependencies` 一致,也是插件实际开发/测试的版本(开发依赖用 2026.9.4)。本插件在旧版宿主上仍可用(见下)。 |
10
+ | Node.js | **>=20** | 插件的 `engines`。宿主 OpenClaw 2026.9.4 自身要求 `>=24.16.0 <25 \|\| >=26.1.0`,实际部署按宿主口径;实测本插件产物在 Node 22.23.2 下也能正常加载。 |
11
+
12
+ ### 跨 OpenClaw 版本兼容
13
+
14
+ OpenClaw 2026.9.x 调整了入站 @ 门控 API:`resolveMentionGatingWithBypass`(扁平入参)被移除,改为 `resolveInboundMentionDecision`(`{ facts, policy }` 两段式),两者**判定结果字段相同**。
15
+
16
+ 插件通过 `src/mention-gate.ts` 做运行时探测:优先用新 API,旧宿主回落到旧 API,两者都没有才报错。因此**升级插件不会让旧版 OpenClaw 用户失效**:
17
+
18
+ | 宿主 OpenClaw | `resolveInboundMentionDecision` | `resolveMentionGatingWithBypass` | 插件行为 |
19
+ | --- | --- | --- | --- |
20
+ | 2026.7.1-2(实测) | 有 | 有 | 走新 API;与宿主原生判定**逐字段一致**(15/15 项对照通过) |
21
+ | 2026.9.4(实测) | 有 | 已移除 | 走新 API |
22
+ | 更早版本(未实测) | 未知 | 未知 | 两者都缺时报明确错误,不会静默放行或静默丢弃 |
23
+
24
+ > 说明:插件对 `openclaw/plugin-sdk/*` 一律使用 `import type` 或命名空间导入 + 运行时取值,避免"宿主缺少某个导出"变成 `import` 阶段的链接失败。
25
+
26
+ 知识库 Tool 需要 OpenClaw 提供 Tool hook 参数调整能力(`before_tool_call` 可返回调整后的 `params`),该能力自 `2026.7.1-2` 起具备。可信有度 IM 上下文由插件按 OpenClaw `runId` 短期保存,并在本轮 dispatch 结束时清理;插件不会在注册结束后调用注册期 API。
27
+
28
+ 安全边界:知识库调用的短期 `invocationToken` 只由插件 hook 注入并一次性消费,不进入 CLI 请求、stdin、Tool 返回值或插件日志。OpenClaw 会在进程内保存调整后的参数,并将其提供给 `after_tool_call`;同时完整调整参数可能出现在宿主受控的 private diagnostic payload 和 loop-tracking 内存中。该 token 不写入模型会话 transcript,也不作为模型工具 schema 字段。宿主诊断的可见范围由 OpenClaw 控制,部署时应按受控诊断数据处理。
8
29
 
9
30
  ## 功能特性
10
31
 
@@ -60,13 +81,54 @@ openclaw gateway restart
60
81
  | `channels.youdu.enabled` | 启用频道 | true / false | false |
61
82
  | `channels.youdu.url` | 有度服务器地址(纯主机名) | — | — |
62
83
  | `channels.youdu.dmPolicy` | 私聊访问策略 | pairing / open / allowlist / disabled | pairing |
63
- | `channels.youdu.allowFrom` | 私聊允许列表 | — | [] |
64
- | `channels.youdu.groups.<群ID>.requireMention` | 该群是否必须 @ 机器人 | true / false | true |
84
+ | `channels.youdu.allowFrom` | 私聊允许列表(账号名、昵称或数字 userId) | — | [] |
85
+ | `channels.youdu.groupPolicy` | 群访问策略 | open / allowlist / disabled | open |
86
+ | `channels.youdu.groupAllowFrom` | 群白名单(仅 `groupPolicy=allowlist` 时生效) | 群 ID、群名或别名,`*` 表示全部 | [] |
87
+ | `channels.youdu.aliases.groups` | 群名 → 群 ID 的显式别名 | 名称 → `{GUID}` | — |
88
+ | `channels.youdu.aliases.users` | 昵称 → 账号名/userId 的显式别名 | 名称 → 账号名或数字 userId | — |
89
+ | `channels.youdu.groups.<群ID或群名>.title` | 群标题(老群兜底;也用于按群名发送) | — | — |
90
+ | `channels.youdu.groups.<群ID或群名>.allowFrom` | 该群内允许触发的发送者 | 账号名或昵称列表(**不接受数字 userId**),`*` 表示全部 | 全部 |
91
+ | `channels.youdu.groups.<群ID或群名>.requireMention` | 该群是否必须 @ 机器人 | true / false | true |
65
92
  | `channels.youdu.groups."*".requireMention` | 所有群的默认 @ 要求 | true / false | true |
66
- | `channels.youdu.historyLimit` | 未被 @ 的群消息保留条数(作为下次被 @ 时的上下文) | 数字,0 表示关闭 | 50 |
93
+ | `channels.youdu.historyLimit` | 未被 @ 的群消息保留条数(作为下次被 @ 时的上下文);**不配置即默认 50**,`0` 关闭 | 数字(≥0) | 50 |
94
+ | `channels.youdu.historyMediaLimit` | 被 @ 时最多补带几张群历史**图片**(作为本轮附件交给模型,进 vision 输入);只取 30 分钟内的,`0` 关闭 | 数字(≥0) | 4 |
95
+ | `channels.youdu.historyFileLimit` | 被 @ 时最多补带几个群历史**文件**(含语音/视频)作为本轮附件;`0` 关闭 | 数字(≥0) | 2 |
67
96
  | `channels.youdu.defaultAccount` | 默认账号 ID | 任意账号 ID | — |
68
97
  | `channels.youdu.accounts` | 多账号配置 | 账号 ID → 账号级字段的映射 | — |
69
98
 
99
+ ### 名称与别名(群 ID / 账号 与「群名 / 昵称」的对应关系)
100
+
101
+ 有度回调里,**用户**会同时带上账号名(`from.account`,如 `lewis.liu`)和昵称(`from.name`,如 `刘钟泽`);
102
+ **群**的 `msg_callback` 只有会话 ID(`{GUID}`),群名只在 `event_callback` 会话事件里下发(`data.title`)。
103
+ 插件因此维护这样一张对应关系:
104
+
105
+ | 对象 | 可寻址的名字 | 权威标识 | 来源 |
106
+ | --- | --- | --- | --- |
107
+ | 用户 | 账号名 `lewis.liu`、昵称 `刘钟泽`、`aliases.users` 里的别名 | 单聊 chatId(= 数字 userId) | `msg_callback.from` |
108
+ | 群 | 会话标题、`groups.<id>.title`、`aliases.groups` 里的别名 | 群会话 ID `{GUID}` | `event_callback` / 配置 |
109
+
110
+ - **群名自动学习**:收到 `session_create` / `session_update` / `session_destroy` 事件时记录 `data.title`,
111
+ 持久化后可显示(`群名 (群 ID)`)、可按群名发送、也可用于 `groupAllowFrom` 与 `groups.<群名>` 配置键。
112
+ - **老群兜底**:机器人入群前就存在、且之后没有改名/建群事件的群,拿不到标题,用配置补:
113
+
114
+ ```bash
115
+ openclaw config set channels.youdu.groups.'{0798E01E-CD68-42BB-813C-EB51593BB0F5}'.title "研发讨论组"
116
+ # 或用别名(别名还支持与群名不同的叫法)
117
+ openclaw config set channels.youdu.aliases.groups.项目群 '{0798E01E-CD68-42BB-813C-EB51593BB0F5}'
118
+ openclaw config set channels.youdu.aliases.users.老板 boss.account
119
+ ```
120
+
121
+ - **不做模糊匹配**:只认精确名字(大小写不敏感回退);账号名优先于同名昵称;重名一律报错要求用 ID/账号名。
122
+ 这是刻意的——飞书 `receive_id`、企业微信 `touser`/`chatid` 也都是 ID 优先,姓名不保证唯一。
123
+ - **群白名单接受群名**:`groupAllowFrom` 与 `groups.<键>` 的键都可写群名或别名,群名歧义时按**拒绝**处理。
124
+ 账号级与通道级的 `groupAllowFrom` 取**并集**,两边都不会因为另一边存在而失效。
125
+ - **发送者白名单接受昵称**:`allowFrom`(私聊)与 `groups.<键>.allowFrom`(群内发送者)里既可以写账号名
126
+ (`lewis`),也可以写昵称/姓名(`刘钟泽`),还可以用 `user:` / `account:` / `name:` 前缀消歧;
127
+ 同样是精确匹配(大小写不敏感),不做模糊匹配。账号名与昵称相同时按命中处理,无需二选一。
128
+ - 私聊还额外接受**数字 userId**:有度单聊的 `chatId` 就是对方 userId,`allowFrom: ["1001"]` 生效。
129
+ - 群内发送者白名单**只认账号名与昵称**:入站消息里群成员的标识就是账号名(`from.account`),
130
+ `groups.<键>.allowFrom: ["1001"]` 不会命中——要按人放行请写账号名或昵称。
131
+
70
132
  ### 群聊 @ 回复(默认)
71
133
 
72
134
  从本版本起,**群聊里只有 @ 了机器人的消息才会触发回复**。行为对齐 OpenClaw 其它渠道的 mention gating:
@@ -76,15 +138,24 @@ openclaw gateway restart
76
138
  仍按现有命令权限执行。
77
139
  - 送进模型前会剥掉消息里的 `@账号(显示名)` 包装,避免模型看到 @ 前缀。
78
140
  - **未被 @ 的消息不会进入会话**:不研究、不下载附件、不发"思考中"占位消息。它们只按群进入一个
79
- **内存中的 pending 窗口**(默认每个群保留最近 50 条,`channels.youdu.historyLimit` 可调,`0` 关闭;
80
- 窗口在每次回复结束、以及进程重启后清空)。下一次被 @ 时,这些消息会以
81
- `[Chat messages since your last reply - for context]` 的形式随当次请求一起注入,让机器人知道群里刚才说了什么。
141
+ **内存中的 pending 窗口**(**默认每个群保留最近 50 条,无需配置**;`channels.youdu.historyLimit` 可调,
142
+ `0` 关闭;窗口在每次回复结束、以及进程重启后清空)。下一次被 @ 时,这些消息会作为结构化历史
143
+ (OpenClaw 的 `InboundHistory`)随当次请求一起注入,让机器人知道群里刚才说了什么。
144
+ - **窗口里的文件/图片会在被 @ 时补带**(只记事实、不提前下载):被 @ 前 30 分钟内的图片最多补
145
+ `channels.youdu.historyMediaLimit` 张(默认 4)、文件/语音/视频最多补 `channels.youdu.historyFileLimit`
146
+ 个(默认 2),都作为**本轮附件**交给模型(图片进 vision 输入,文件作为附件)。
147
+ 超出时间窗口或下载失败的媒体只保留文本历史,不会拖垮这一轮回复。
148
+ (`requireMention: false` 的群不产生这个窗口——它每条消息都会触发一轮,会话自身就带上下文。)
149
+ - 窗口大小按 `账号级 historyLimit` → `channels.youdu.historyLimit` → `messages.groupChat.historyLimit`
150
+ (OpenClaw 全局群聊配置)→ 内置默认 50 依次取值,四层都不配就是 50;多账号模式下该值在通道级共享。
151
+ 媒体上限按 `账号级` → `通道级` → 默认(图片 4、文件 2)取值。
82
152
  - 判定能力缺失时(认证应答没有 `gid`,也没有配置 `mentionPatterns`)**宁可不回**:会记录一条错误日志并丢弃
83
153
  该消息,而不会退化成"群里全回"。
84
- - 想让某个群恢复"全部回复",在该群配置里显式关闭即可:
154
+ - 想让某个群恢复"全部回复",在该群配置里显式关闭即可(键可以写群 ID,也可以写群名):
85
155
 
86
156
  ```bash
87
157
  openclaw config set channels.youdu.groups.'{0798E01E-CD68-42BB-813C-EB51593BB0F5}'.requireMention false
158
+ openclaw config set channels.youdu.groups.'研发讨论组'.requireMention false
88
159
  ```
89
160
 
90
161
  - 群访问仍然先由 `channels.youdu.groupPolicy`(open / allowlist / disabled)控制,@ 判定在其后。
@@ -142,7 +213,7 @@ openclaw gateway restart
142
213
  - 顶层字段作为默认值:账号级未配置的字段(如 `dmPolicy`、`allowFrom`、`groupPolicy`)会继承顶层值
143
214
  - `defaultAccount` 指定默认账号;未指定时优先使用 `default` 账号,否则取第一个账号
144
215
  - 若顶层同时配置了 `botId`/`secret`/`url`,顶层凭据会作为一个独立的 `default` 账号保留(除非某个命名账号的 botId 与之重复)
145
- - 账号级可用字段与顶层一致:`botId`、`secret`、`url`、`enabled`、`name`、`dmPolicy`、`allowFrom`、`groupPolicy`、`groupAllowFrom`、`sendThinkingMessage`、`mediaMaxMb`、`mediaLocalRoots`
216
+ - 账号级可用字段与顶层一致:`botId`、`secret`、`url`、`enabled`、`name`、`dmPolicy`、`allowFrom`、`groupPolicy`、`groupAllowFrom`、`groups`、`aliases`、`sendThinkingMessage`、`mediaMaxMb`、`mediaLocalRoots`
146
217
  - 查看多账号运行状态:`openclaw channels status youdu`
147
218
  - 仅配置顶层凭据(不配置 `accounts`)时行为与之前完全一致,即为单账号模式
148
219
 
@@ -173,29 +244,46 @@ openclaw channels status youdu
173
244
  # 私聊:数字 userId 直接可用(有度单聊的 chatId 就是对方 userId)
174
245
  openclaw message send --channel youdu --to 6463894 --message "早上好"
175
246
 
176
- # 私聊:用账号名(需该账号先私聊过机器人,插件会记住映射)
247
+ # 私聊:账号名,或该账号的昵称(需该账号先私聊过机器人,插件会记住映射)
177
248
  openclaw message send --channel youdu --to lewis.liu --message "早上好"
249
+ openclaw message send --channel youdu --to 刘钟泽 --message "早上好"
250
+
251
+ # 群聊:群名(会话标题 / groups.<id>.title / aliases.groups 里的名字)
252
+ openclaw message send --channel youdu --to 研发讨论组 --message "群公告"
178
253
 
179
- # 群聊:直接填群 ID,或显式声明类型
254
+ # 群聊:群 ID,或显式声明类型
180
255
  openclaw message send --channel youdu --to "group:{0798E01E-CD68-42BB-813C-EB51593BB0F5}" --message "群公告"
181
256
  ```
182
257
 
183
258
  目标解析顺序(`src/message-sender.ts` 的 `resolveOutboundTarget`):
184
259
 
185
260
  1. 去掉 `youdu:` 前缀,以及可选的显式类型前缀 `group:` / `user:`(`single:` 亦可);
186
- 2. 已学到的私聊账号名 → 该账号的 chatId(单聊);
187
- 3. 已学到的群 ID,或形如 `{GUID}` 的群 ID → 群聊;
188
- 4. 纯数字 → 单聊;
189
- 5. 其余报错,并提示可用写法。
261
+ 2. 形如 `{GUID}` 的群 ID 直接按群处理(ID 是权威标识,不做名称解析);
262
+ 3. 配置别名 `channels.youdu.aliases.users`;
263
+ 4. 已学到的私聊账号名 → 该账号的 chatId(单聊);
264
+ 5. 群名(`aliases.groups` → 已学到的会话标题 → `groups.<id>.title`)→ 群聊;
265
+ 6. 已学到的昵称(精确匹配)→ 单聊;
266
+ 7. 纯数字 → 单聊;
267
+ 8. 其余报错,并提示可用写法。
268
+
269
+ **名称撞车怎么办**(群名/昵称在服务端都不保证唯一,因此不做模糊匹配):
270
+
271
+ - 账号名优先于同名昵称:`lewis.liu` 永远解析到本人,不会被别人的同名昵称抢走;
272
+ - 昵称重名(两个「张伟」)→ 报错并列出候选账号名,请改用账号名或数字 userId;
273
+ - 群名重名(两个「项目群」)→ 报错并列出候选群 ID,请改用群 ID 或加 `aliases.groups` 消歧;
274
+ - 只做精确匹配:`刘` 不会匹配 `刘钟泽`。
190
275
 
191
276
  学习与持久化(`src/target-store.ts`):
192
277
 
193
- - 收到私聊消息时记录 `账号名 → chatId` 与昵称,收到群消息时记录群 ID;
278
+ - 收到私聊消息时记录 `账号名 → chatId` 与昵称;收到群消息时记录群 ID;
279
+ - 消费 `event_callback` 会话事件,把 `data.title` 记为群名(`session_update` 的新标题为空时回退 `oldSession.title`),
280
+ 因此群在 `directory` 里显示为 `群名 (群 ID)`,也能直接按群名发送;
281
+ - 老群(机器人入群前就存在、拿不到会话事件)用 `channels.youdu.groups."<群 ID>".title` 或 `aliases.groups` 兜底;
194
282
  - 记录写入 `~/.openclaw/youdu-targets.json`(权限 0600、先写临时文件再 rename、变更后去抖落盘),
195
283
  **进程重启后仍然可用**;
196
284
  - 账号从配置中移除时会一并清掉该账号的目标;
197
285
  - `openclaw channels directory youdu`(`directory.listPeers` / `listGroups`)会列出这些已聊过的联系人与群,
198
- 方便确认可投递目标;有度回调不下发群名,因此群只显示 ID。
286
+ 方便确认可投递目标。
199
287
 
200
288
  > 注意:`send_msg` 必须带 `chatType`(`single`/`group`)。有度服务端只在 `chatType === 'group'`
201
289
  > 时按群会话投递,其余值一律按单聊处理,漏传会把群 ID 当成用户 ID 发成私聊。
@@ -226,22 +226,23 @@ function createYouDuPlugin(policy, monitor = monitorYouDuProvider, lifecycleObse
226
226
  const trimmed = id?.trim();
227
227
  return Boolean(trimmed);
228
228
  },
229
- hint: '<userId|groupId|group:<chatId>|user:<account>>',
229
+ hint: '<userId|account|userName|groupId|groupName|group:<chatId>|user:<account>>',
230
230
  },
231
231
  },
232
232
  directory: {
233
233
  self: async () => null,
234
234
  // 已聊过的联系人(id 就是主动发送用的 chatId;有度单聊 chatId = 对方 userId)。
235
235
  listPeers: async ({ accountId, query, limit }) => listPeers({ accountId, query, limit }),
236
- // 见过消息的群;有度回调不下发群名,因此只有 ID。
236
+ // 见过的群:有标题时显示 `群名 (ID)`;标题来自 event_callback 或配置 groups.<id>.title。
237
237
  listGroups: async ({ accountId, query, limit }) => listGroups({ accountId, query, limit }),
238
238
  },
239
239
  outbound: {
240
240
  deliveryMode: "gateway",
241
241
  chunker: (text, limit) => getYouDuRuntime().channel.text.chunkMarkdownText(text, limit),
242
242
  textChunkLimit: TEXT_CHUNK_LIMIT,
243
- sendText: async ({ to, text, accountId }) => {
243
+ sendText: async ({ cfg, to, text, accountId }) => {
244
244
  return sendYouDuTextMessage({
245
+ cfg,
245
246
  to,
246
247
  text,
247
248
  accountId,
@@ -16,6 +16,8 @@ export interface DmPolicyCheckResult {
16
16
  */
17
17
  export declare function checkDmPolicy(params: {
18
18
  senderId: string;
19
+ /** 发送者昵称(`from.name`):白名单里写昵称同样生效。 */
20
+ senderName?: string;
19
21
  isGroup: boolean;
20
22
  account: ResolvedYouDuAccount;
21
23
  wsClient: WSClient;
@@ -14,7 +14,7 @@ import { sendYouDuReply } from './message-sender.js';
14
14
  * @returns 检查结果,包含是否允许继续处理
15
15
  */
16
16
  async function checkDmPolicy(params) {
17
- const { senderId, isGroup, account, wsClient, frame, runtime } = params;
17
+ const { senderId, senderName, isGroup, account, wsClient, frame, runtime } = params;
18
18
  const core = getYouDuRuntime();
19
19
  // 群聊消息不检查 DM Policy
20
20
  if (isGroup) {
@@ -35,7 +35,7 @@ async function checkDmPolicy(params) {
35
35
  .readAllowFromStore({ channel: CHANNEL_ID, accountId: account.accountId })
36
36
  .catch(() => []);
37
37
  const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
38
- const senderAllowedResult = isSenderAllowed(senderId, effectiveAllowFrom);
38
+ const senderAllowedResult = isSenderAllowed(senderId, senderName, effectiveAllowFrom);
39
39
  if (senderAllowedResult) {
40
40
  return { allowed: true };
41
41
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 有度 `event_callback` 会话事件解析。
3
+ *
4
+ * 群名只在会话事件里下发(`msg_callback` 只有 chatId/sessionId),所以插件必须消费
5
+ * 这一路:`session_create` / `session_destroy` / `session_update` 的 `data.title`。
6
+ *
7
+ * 服务端历史版本用 snake_case(`event_type` / `sessionid`),当前版本用 camelCase
8
+ * (`eventType` / `sessionId`),协议明确不再兼容旧字段名,但事件是**推送**的,
9
+ * 插件跨版本在线时两种都可能收到,所以这里两种都读。
10
+ */
11
+ /** 我们需要的一小段事实:哪个会话、标题是什么、是群还是单聊。 */
12
+ export interface YouduSessionEventInfo {
13
+ sessionId: string;
14
+ title: string;
15
+ /**
16
+ * `data.type`(服务端下发 `group` / `direct`)。
17
+ *
18
+ * 有度的「讨论组」也是多人会话,但它是 direct 语义、标题形如 `xxx创建的讨论组`;
19
+ * 只有真正按群会话投递(`chatType=group`)的会话才该进群存储,否则单聊的数字会话
20
+ * ID 会被误判成群,回复走错通道。
21
+ */
22
+ chatType: "group" | "direct";
23
+ }
24
+ /**
25
+ * 会话事件的排序戳:优先 `version`(服务端按版本单调递增,重命名后递增),
26
+ * 其次 `createTime`。两者都没有时返回 0,由调用方回落到本地时间。
27
+ */
28
+ export declare function sessionEventStamp(body: unknown): number;
29
+ /**
30
+ * 解析一帧 `event_callback` 的 `body`。
31
+ *
32
+ * 返回 `null` 表示这条事件与群标题无关(未知事件类型、缺 sessionId),调用方直接忽略。
33
+ */
34
+ export declare function parseSessionEvent(body: unknown, onUnknownEventType?: (eventType: string) => void): YouduSessionEventInfo | null;
@@ -0,0 +1,105 @@
1
+ /*
2
+ * Licensed under the MIT License.
3
+ */
4
+ const SESSION_EVENT_TYPES = new Set(["session_create", "session_destroy", "session_update"]);
5
+ /**
6
+ * 事件类型归一化。
7
+ *
8
+ * 服务端 `ng_bot` 实际下发小写下划线(`session_create` / `session_update` / `session_destroy`,
9
+ * 见 `nsq_msg_consumer.go` 的 `makeSessionEventEnvelope`),但仓库的协议文档示例写的是
10
+ * `sessionUpdated` 这类 camelCase。两种都接受,避免文档变更时插件静默失效。
11
+ */
12
+ function normalizeEventType(value) {
13
+ const raw = value.trim();
14
+ if (!raw)
15
+ return "";
16
+ const snake = raw
17
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
18
+ .replace(/[\s-]+/g, "_")
19
+ .toLowerCase();
20
+ const aliases = {
21
+ // 下划线形态
22
+ session_create: "session_create",
23
+ session_destroy: "session_destroy",
24
+ session_update: "session_update",
25
+ // camelCase 形态(转换后为 session_created / session_updated)
26
+ session_created: "session_create",
27
+ session_destroyed: "session_destroy",
28
+ session_updated: "session_update",
29
+ };
30
+ return aliases[snake] ?? snake;
31
+ }
32
+ /** `data.type` 只有明确是群时才当群,其余(含缺失)按 direct。 */
33
+ function normalizeSessionChatType(value) {
34
+ return value.toLowerCase() === "group" ? "group" : "direct";
35
+ }
36
+ function asRecord(value) {
37
+ if (!value || typeof value !== "object" || Array.isArray(value))
38
+ return null;
39
+ return value;
40
+ }
41
+ /** 读第一个非空字符串字段(键名区分大小写,按传入顺序)。 */
42
+ function readString(source, ...keys) {
43
+ for (const key of keys) {
44
+ const value = source[key];
45
+ if (typeof value === "string" && value.trim())
46
+ return value.trim();
47
+ }
48
+ return "";
49
+ }
50
+ /**
51
+ * 会话事件的排序戳:优先 `version`(服务端按版本单调递增,重命名后递增),
52
+ * 其次 `createTime`。两者都没有时返回 0,由调用方回落到本地时间。
53
+ */
54
+ function sessionEventStamp(body) {
55
+ const record = asRecord(body);
56
+ if (!record)
57
+ return 0;
58
+ for (const key of ["version", "createTime", "create_time"]) {
59
+ const value = record[key];
60
+ if (typeof value === "number" && Number.isFinite(value) && value > 0)
61
+ return value;
62
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) {
63
+ const parsed = Number(value);
64
+ if (parsed > 0)
65
+ return parsed;
66
+ }
67
+ }
68
+ return 0;
69
+ }
70
+ /**
71
+ * 解析一帧 `event_callback` 的 `body`。
72
+ *
73
+ * 返回 `null` 表示这条事件与群标题无关(未知事件类型、缺 sessionId),调用方直接忽略。
74
+ */
75
+ function parseSessionEvent(body, onUnknownEventType) {
76
+ const record = asRecord(body);
77
+ if (!record)
78
+ return null;
79
+ const rawEventType = readString(record, "eventType", "event_type");
80
+ const eventType = normalizeEventType(rawEventType);
81
+ if (!SESSION_EVENT_TYPES.has(eventType)) {
82
+ // 未知事件类型静默丢弃会掩盖协议漂移(例如服务端改名、文档示例与实现不一致)。
83
+ if (rawEventType)
84
+ onUnknownEventType?.(rawEventType);
85
+ return null;
86
+ }
87
+ const sessionId = readString(record, "sessionId", "sessionid", "session_id");
88
+ if (!sessionId)
89
+ return null;
90
+ const data = asRecord(record.data);
91
+ let title = data ? readString(data, "title") : "";
92
+ if (!title && data) {
93
+ // session_update 重命名时新标题可能为空,回退到变更前的标题,避免标题被清掉。
94
+ const oldSession = asRecord(data.oldSession) ?? asRecord(data.old_session);
95
+ if (oldSession)
96
+ title = readString(oldSession, "title");
97
+ }
98
+ return {
99
+ sessionId,
100
+ title,
101
+ chatType: normalizeSessionChatType(data ? readString(data, "type") : ""),
102
+ };
103
+ }
104
+
105
+ export { parseSessionEvent, sessionEventStamp };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 群聊 pending 历史窗口:未被 @ 的群消息在这里排队,等下一次被 @ 时作为上下文交给模型。
3
+ *
4
+ * 两条通道,各管一件事:
5
+ * - **文本历史**走宿主的 `InboundHistory`:宿主渲染成 "Chat history since last reply:" 块(含
6
+ * "把人类写的名字/群名/引用/历史当不可信内容" 的说明),并保留条目里的媒体类型标注。
7
+ * - **媒体本体**走**当前轮** `ctx.media`:图片和文件一样,都必须在这里,宿主才会读。
8
+ * 注意宿主的"最近历史图片"通道(`InboundHistory[].media` + 本地 path)**默认回复链路是关的**
9
+ * (`current-turn-images` 显式传 `includeRecentHistoryImages: false`,只有 ACP 绑定会话才开),
10
+ * 所以不能把图片塞进历史条目就指望它到模型。
11
+ *
12
+ * 记录时**不下载**:跳过消息可能在群里刷很久而机器人一次都没被 @,下载全部媒体是纯浪费。
13
+ * 只有真正触发一轮时,才按时间窗口和上限把这几条媒体拉下来,作为本轮附件附上。
14
+ */
15
+ import { type HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
16
+ import type { Youdu } from './media.js';
17
+ /**
18
+ * 补带历史媒体的时间窗口:只把"刚刚发的"算作本轮上下文。
19
+ *
20
+ * 这是插件自己的策略(不是宿主常量):群里半小时前的图不该冒充当前这句 @ 的附件。
21
+ */
22
+ export declare const HISTORY_MEDIA_TTL_MS: number;
23
+ /** 被 @ 时最多补带几张历史图片。 */
24
+ export declare const DEFAULT_HISTORY_IMAGE_LIMIT = 4;
25
+ /** 被 @ 时最多补带几个历史文件(含语音/视频)。 */
26
+ export declare const DEFAULT_HISTORY_FILE_LIMIT = 2;
27
+ /** 宿主的 `MediaKind`(openclaw dist/constants)。有度没有 sticker/unknown,文件在宿主口径里叫 document。 */
28
+ export type HostMediaKind = 'image' | 'audio' | 'video' | 'document' | 'sticker' | 'unknown';
29
+ /** 一条跳过消息带过的媒体事实:记录时不下载,只记够下载用的东西。 */
30
+ export interface PendingMediaFact {
31
+ mediaId: string;
32
+ /** 宿主口径的媒体类型;下载时再用 `toYouduDownloadKind` 换回有度的 `kind` 参数。 */
33
+ kind: HostMediaKind;
34
+ name?: string;
35
+ contentType?: string;
36
+ /** 下载完成后回填;宿主的 history 图片通道只认本地 path。 */
37
+ path?: string;
38
+ }
39
+ /** One skipped group message kept for the next addressed turn. */
40
+ export interface PendingGroupEntry {
41
+ sender: string;
42
+ body: string;
43
+ timestamp?: number;
44
+ messageId?: string;
45
+ /** 这条消息带的媒体(未下载)。 */
46
+ media?: PendingMediaFact[];
47
+ }
48
+ /** 从窗口里挑出的一条待下载媒体。 */
49
+ export interface PendingMediaSelection {
50
+ entryIndex: number;
51
+ factIndex: number;
52
+ fact: PendingMediaFact;
53
+ }
54
+ /**
55
+ * Pending-window size: account → channel → `messages.groupChat` → fallback, clamped at 0.
56
+ * A resolved 0 disables recording, injection, and clearing in the shared helpers.
57
+ */
58
+ export declare function resolveHistoryLimit(params: {
59
+ accountHistoryLimit?: unknown;
60
+ channelHistoryLimit?: unknown;
61
+ globalHistoryLimit?: unknown;
62
+ fallback: number;
63
+ }): number;
64
+ /** One pending entry, or null when the message carries nothing worth replaying. */
65
+ export declare function pendingHistoryEntry(params: {
66
+ messageId?: string;
67
+ sender?: string;
68
+ body?: string;
69
+ timestamp?: number;
70
+ media?: readonly PendingMediaFact[];
71
+ }): PendingGroupEntry | null;
72
+ /** A redelivered frame must not fill the window with duplicates of the same message. */
73
+ export declare function shouldRecordPendingEntry(entries: readonly PendingGroupEntry[] | undefined, messageId?: string): boolean;
74
+ /**
75
+ * 取走窗口里当前这批条目:**注入即认领**。
76
+ *
77
+ * 比"run 结束再整段清空"更准:这一轮跑着的时候群里新到的消息属于下一次触发,不会被收尾顺手清掉;
78
+ * 同一批也不会被并发的两轮各注入一次。窗口本身就是内存态(进程重启即丢),所以"认领后这一轮
79
+ * 挂了就少了这批上下文"与原来"run 结束一律清空"的后果相同。
80
+ */
81
+ export declare function claimPendingEntries(params: {
82
+ historyMap: Map<string, PendingGroupEntry[]>;
83
+ historyKey: string;
84
+ }): PendingGroupEntry[];
85
+ /**
86
+ * 把解析出的入站媒体变成窗口里的事实,只保留能下载的(有 mediaId)。
87
+ */
88
+ export declare function mediaFactsForAttachments(attachments: readonly Youdu.InboundMediaAttachment[] | undefined): PendingMediaFact[];
89
+ /** 有度的 `file` 在宿主里是 `document`,其余同名。 */
90
+ export declare function toHostMediaKind(kind: Youdu.InboundMediaAttachment['kind']): HostMediaKind;
91
+ /** 下载 URL 的 `kind` 参数只认有度自己的取值(`GET /cgi/bot/file/download?kind=…`)。 */
92
+ export declare function toYouduDownloadKind(kind: HostMediaKind): Youdu.InboundMediaAttachment['kind'];
93
+ /**
94
+ * 挑出要补带的历史媒体:最新优先,图片和文件各自计数,条目时间必须落在补带窗口内。
95
+ *
96
+ * 时间用 `Math.abs` 比较,和宿主处理"最近历史"时的口径一致:服务端时钟略快、条目时间比当前消息
97
+ * 晚几秒时,不该把它判成过期。
98
+ */
99
+ export declare function selectPendingMedia(params: {
100
+ entries: readonly PendingGroupEntry[];
101
+ nowMs: number;
102
+ ttlMs?: number;
103
+ imageLimit: number;
104
+ fileLimit: number;
105
+ }): {
106
+ images: PendingMediaSelection[];
107
+ files: PendingMediaSelection[];
108
+ };
109
+ /** 下载成功的一条历史媒体。 */
110
+ export interface DownloadedHistoryMedia {
111
+ selection: PendingMediaSelection;
112
+ path: string;
113
+ contentType?: string;
114
+ }
115
+ /** 作为**当前轮**媒体附上的 canonical fact(宿主的 `ctx.media` 口径)。 */
116
+ export interface TurnMediaFact {
117
+ path: string;
118
+ contentType?: string;
119
+ kind: HostMediaKind;
120
+ fileName?: string;
121
+ }
122
+ /** 下载结果 → 当前轮媒体 fact(图片和文件都走这里,宿主只读 `ctx.media`)。 */
123
+ export declare function turnMediaFacts(downloads: readonly DownloadedHistoryMedia[]): TurnMediaFact[];
124
+ /**
125
+ * 一轮投递要用的群上下文:把窗口里该带的媒体下载下来,产出
126
+ * `InboundHistory`(文本历史)和 `turnMedia`(本轮附件,图片+文件)。
127
+ *
128
+ * 下载函数由调用方注入(真实实现走有度 HTTP 下载),单条失败不抛:历史文本仍然保留,
129
+ * 模型至少知道谁在什么时候发过东西。
130
+ */
131
+ export declare function resolveHistoryForTurn(params: {
132
+ entries: readonly PendingGroupEntry[];
133
+ nowMs: number;
134
+ imageLimit: number;
135
+ fileLimit: number;
136
+ ttlMs?: number;
137
+ download: (fact: PendingMediaFact) => Promise<{
138
+ path: string;
139
+ contentType?: string;
140
+ }>;
141
+ }): Promise<{
142
+ inboundHistory: HistoryEntry[] | undefined;
143
+ turnMedia: TurnMediaFact[];
144
+ }>;