@youdu/openclaw-youdu 2026.9.15 → 2026.9.16

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,52 @@ 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 |
67
94
  | `channels.youdu.defaultAccount` | 默认账号 ID | 任意账号 ID | — |
68
95
  | `channels.youdu.accounts` | 多账号配置 | 账号 ID → 账号级字段的映射 | — |
69
96
 
97
+ ### 名称与别名(群 ID / 账号 与「群名 / 昵称」的对应关系)
98
+
99
+ 有度回调里,**用户**会同时带上账号名(`from.account`,如 `lewis.liu`)和昵称(`from.name`,如 `刘钟泽`);
100
+ **群**的 `msg_callback` 只有会话 ID(`{GUID}`),群名只在 `event_callback` 会话事件里下发(`data.title`)。
101
+ 插件因此维护这样一张对应关系:
102
+
103
+ | 对象 | 可寻址的名字 | 权威标识 | 来源 |
104
+ | --- | --- | --- | --- |
105
+ | 用户 | 账号名 `lewis.liu`、昵称 `刘钟泽`、`aliases.users` 里的别名 | 单聊 chatId(= 数字 userId) | `msg_callback.from` |
106
+ | 群 | 会话标题、`groups.<id>.title`、`aliases.groups` 里的别名 | 群会话 ID `{GUID}` | `event_callback` / 配置 |
107
+
108
+ - **群名自动学习**:收到 `session_create` / `session_update` / `session_destroy` 事件时记录 `data.title`,
109
+ 持久化后可显示(`群名 (群 ID)`)、可按群名发送、也可用于 `groupAllowFrom` 与 `groups.<群名>` 配置键。
110
+ - **老群兜底**:机器人入群前就存在、且之后没有改名/建群事件的群,拿不到标题,用配置补:
111
+
112
+ ```bash
113
+ openclaw config set channels.youdu.groups.'{0798E01E-CD68-42BB-813C-EB51593BB0F5}'.title "研发讨论组"
114
+ # 或用别名(别名还支持与群名不同的叫法)
115
+ openclaw config set channels.youdu.aliases.groups.项目群 '{0798E01E-CD68-42BB-813C-EB51593BB0F5}'
116
+ openclaw config set channels.youdu.aliases.users.老板 boss.account
117
+ ```
118
+
119
+ - **不做模糊匹配**:只认精确名字(大小写不敏感回退);账号名优先于同名昵称;重名一律报错要求用 ID/账号名。
120
+ 这是刻意的——飞书 `receive_id`、企业微信 `touser`/`chatid` 也都是 ID 优先,姓名不保证唯一。
121
+ - **群白名单接受群名**:`groupAllowFrom` 与 `groups.<键>` 的键都可写群名或别名,群名歧义时按**拒绝**处理。
122
+ 账号级与通道级的 `groupAllowFrom` 取**并集**,两边都不会因为另一边存在而失效。
123
+ - **发送者白名单接受昵称**:`allowFrom`(私聊)与 `groups.<键>.allowFrom`(群内发送者)里既可以写账号名
124
+ (`lewis`),也可以写昵称/姓名(`刘钟泽`),还可以用 `user:` / `account:` / `name:` 前缀消歧;
125
+ 同样是精确匹配(大小写不敏感),不做模糊匹配。账号名与昵称相同时按命中处理,无需二选一。
126
+ - 私聊还额外接受**数字 userId**:有度单聊的 `chatId` 就是对方 userId,`allowFrom: ["1001"]` 生效。
127
+ - 群内发送者白名单**只认账号名与昵称**:入站消息里群成员的标识就是账号名(`from.account`),
128
+ `groups.<键>.allowFrom: ["1001"]` 不会命中——要按人放行请写账号名或昵称。
129
+
70
130
  ### 群聊 @ 回复(默认)
71
131
 
72
132
  从本版本起,**群聊里只有 @ 了机器人的消息才会触发回复**。行为对齐 OpenClaw 其它渠道的 mention gating:
@@ -76,15 +136,18 @@ openclaw gateway restart
76
136
  仍按现有命令权限执行。
77
137
  - 送进模型前会剥掉消息里的 `@账号(显示名)` 包装,避免模型看到 @ 前缀。
78
138
  - **未被 @ 的消息不会进入会话**:不研究、不下载附件、不发"思考中"占位消息。它们只按群进入一个
79
- **内存中的 pending 窗口**(默认每个群保留最近 50 条,`channels.youdu.historyLimit` 可调,`0` 关闭;
80
- 窗口在每次回复结束、以及进程重启后清空)。下一次被 @ 时,这些消息会以
139
+ **内存中的 pending 窗口**(**默认每个群保留最近 50 条,无需配置**;`channels.youdu.historyLimit` 可调,
140
+ `0` 关闭;窗口在每次回复结束、以及进程重启后清空)。下一次被 @ 时,这些消息会以
81
141
  `[Chat messages since your last reply - for context]` 的形式随当次请求一起注入,让机器人知道群里刚才说了什么。
142
+ - 窗口大小按 `账号级 historyLimit` → `channels.youdu.historyLimit` → `messages.groupChat.historyLimit`
143
+ (OpenClaw 全局群聊配置)→ 内置默认 50 依次取值,四层都不配就是 50;多账号模式下该值在通道级共享。
82
144
  - 判定能力缺失时(认证应答没有 `gid`,也没有配置 `mentionPatterns`)**宁可不回**:会记录一条错误日志并丢弃
83
145
  该消息,而不会退化成"群里全回"。
84
- - 想让某个群恢复"全部回复",在该群配置里显式关闭即可:
146
+ - 想让某个群恢复"全部回复",在该群配置里显式关闭即可(键可以写群 ID,也可以写群名):
85
147
 
86
148
  ```bash
87
149
  openclaw config set channels.youdu.groups.'{0798E01E-CD68-42BB-813C-EB51593BB0F5}'.requireMention false
150
+ openclaw config set channels.youdu.groups.'研发讨论组'.requireMention false
88
151
  ```
89
152
 
90
153
  - 群访问仍然先由 `channels.youdu.groupPolicy`(open / allowlist / disabled)控制,@ 判定在其后。
@@ -142,7 +205,7 @@ openclaw gateway restart
142
205
  - 顶层字段作为默认值:账号级未配置的字段(如 `dmPolicy`、`allowFrom`、`groupPolicy`)会继承顶层值
143
206
  - `defaultAccount` 指定默认账号;未指定时优先使用 `default` 账号,否则取第一个账号
144
207
  - 若顶层同时配置了 `botId`/`secret`/`url`,顶层凭据会作为一个独立的 `default` 账号保留(除非某个命名账号的 botId 与之重复)
145
- - 账号级可用字段与顶层一致:`botId`、`secret`、`url`、`enabled`、`name`、`dmPolicy`、`allowFrom`、`groupPolicy`、`groupAllowFrom`、`sendThinkingMessage`、`mediaMaxMb`、`mediaLocalRoots`
208
+ - 账号级可用字段与顶层一致:`botId`、`secret`、`url`、`enabled`、`name`、`dmPolicy`、`allowFrom`、`groupPolicy`、`groupAllowFrom`、`groups`、`aliases`、`sendThinkingMessage`、`mediaMaxMb`、`mediaLocalRoots`
146
209
  - 查看多账号运行状态:`openclaw channels status youdu`
147
210
  - 仅配置顶层凭据(不配置 `accounts`)时行为与之前完全一致,即为单账号模式
148
211
 
@@ -173,29 +236,46 @@ openclaw channels status youdu
173
236
  # 私聊:数字 userId 直接可用(有度单聊的 chatId 就是对方 userId)
174
237
  openclaw message send --channel youdu --to 6463894 --message "早上好"
175
238
 
176
- # 私聊:用账号名(需该账号先私聊过机器人,插件会记住映射)
239
+ # 私聊:账号名,或该账号的昵称(需该账号先私聊过机器人,插件会记住映射)
177
240
  openclaw message send --channel youdu --to lewis.liu --message "早上好"
241
+ openclaw message send --channel youdu --to 刘钟泽 --message "早上好"
242
+
243
+ # 群聊:群名(会话标题 / groups.<id>.title / aliases.groups 里的名字)
244
+ openclaw message send --channel youdu --to 研发讨论组 --message "群公告"
178
245
 
179
- # 群聊:直接填群 ID,或显式声明类型
246
+ # 群聊:群 ID,或显式声明类型
180
247
  openclaw message send --channel youdu --to "group:{0798E01E-CD68-42BB-813C-EB51593BB0F5}" --message "群公告"
181
248
  ```
182
249
 
183
250
  目标解析顺序(`src/message-sender.ts` 的 `resolveOutboundTarget`):
184
251
 
185
252
  1. 去掉 `youdu:` 前缀,以及可选的显式类型前缀 `group:` / `user:`(`single:` 亦可);
186
- 2. 已学到的私聊账号名 → 该账号的 chatId(单聊);
187
- 3. 已学到的群 ID,或形如 `{GUID}` 的群 ID → 群聊;
188
- 4. 纯数字 → 单聊;
189
- 5. 其余报错,并提示可用写法。
253
+ 2. 形如 `{GUID}` 的群 ID 直接按群处理(ID 是权威标识,不做名称解析);
254
+ 3. 配置别名 `channels.youdu.aliases.users`;
255
+ 4. 已学到的私聊账号名 → 该账号的 chatId(单聊);
256
+ 5. 群名(`aliases.groups` → 已学到的会话标题 → `groups.<id>.title`)→ 群聊;
257
+ 6. 已学到的昵称(精确匹配)→ 单聊;
258
+ 7. 纯数字 → 单聊;
259
+ 8. 其余报错,并提示可用写法。
260
+
261
+ **名称撞车怎么办**(群名/昵称在服务端都不保证唯一,因此不做模糊匹配):
262
+
263
+ - 账号名优先于同名昵称:`lewis.liu` 永远解析到本人,不会被别人的同名昵称抢走;
264
+ - 昵称重名(两个「张伟」)→ 报错并列出候选账号名,请改用账号名或数字 userId;
265
+ - 群名重名(两个「项目群」)→ 报错并列出候选群 ID,请改用群 ID 或加 `aliases.groups` 消歧;
266
+ - 只做精确匹配:`刘` 不会匹配 `刘钟泽`。
190
267
 
191
268
  学习与持久化(`src/target-store.ts`):
192
269
 
193
- - 收到私聊消息时记录 `账号名 → chatId` 与昵称,收到群消息时记录群 ID;
270
+ - 收到私聊消息时记录 `账号名 → chatId` 与昵称;收到群消息时记录群 ID;
271
+ - 消费 `event_callback` 会话事件,把 `data.title` 记为群名(`session_update` 的新标题为空时回退 `oldSession.title`),
272
+ 因此群在 `directory` 里显示为 `群名 (群 ID)`,也能直接按群名发送;
273
+ - 老群(机器人入群前就存在、拿不到会话事件)用 `channels.youdu.groups."<群 ID>".title` 或 `aliases.groups` 兜底;
194
274
  - 记录写入 `~/.openclaw/youdu-targets.json`(权限 0600、先写临时文件再 rename、变更后去抖落盘),
195
275
  **进程重启后仍然可用**;
196
276
  - 账号从配置中移除时会一并清掉该账号的目标;
197
277
  - `openclaw channels directory youdu`(`directory.listPeers` / `listGroups`)会列出这些已聊过的联系人与群,
198
- 方便确认可投递目标;有度回调不下发群名,因此群只显示 ID。
278
+ 方便确认可投递目标。
199
279
 
200
280
  > 注意:`send_msg` 必须带 `chatType`(`single`/`group`)。有度服务端只在 `chatType === 'group'`
201
281
  > 时按群会话投递,其余值一律按单聊处理,漏传会把群 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 };
@@ -1,6 +1,6 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
2
  import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
3
- import type { ResolvedYouDuAccount } from "./utils.js";
3
+ import type { ResolvedYouDuAccount, YouDuGroupConfig } from "./utils.js";
4
4
  /**
5
5
  * 群组策略检查结果
6
6
  */
@@ -15,11 +15,35 @@ export interface GroupPolicyCheckResult {
15
15
  export declare function checkGroupPolicy(params: {
16
16
  chatId: string;
17
17
  senderId: string;
18
+ /** 发送者昵称(`from.name`);与账号名一样可写在白名单里。 */
19
+ senderName?: string;
18
20
  account: ResolvedYouDuAccount;
19
21
  config: OpenClawConfig;
20
22
  runtime: RuntimeEnv;
23
+ /** 账号 ID(多账号时用于读取该账号的别名表)。 */
24
+ accountId?: string | null;
25
+ /**
26
+ * 生效的 `groups` 配置表(已合并顶层与账号级、键可写群 ID 或群名)。
27
+ *
28
+ * 显式传入以免再按账号 ID 反查配置:测试与多账号场景下更可预测。
29
+ */
30
+ groups?: Record<string, YouDuGroupConfig>;
21
31
  }): GroupPolicyCheckResult;
22
32
  /**
23
- * 检查发送者是否在允许列表中(通用)
33
+ * 解析该群生效的 `requireMention`(`groups.<群 ID 或群名>` 优先,其次 `"*"`,默认 true)。
24
34
  */
25
- export declare function isSenderAllowed(senderId: string, allowFrom: string[]): boolean;
35
+ export declare function resolveGroupRequireMentionFor(params: {
36
+ config: OpenClawConfig;
37
+ accountId?: string | null;
38
+ chatId: string;
39
+ /** 生效的 `groups` 配置表(已合并顶层与账号级);省略时只读通道顶层配置。 */
40
+ groups?: Record<string, YouDuGroupConfig>;
41
+ fallback?: (groups: unknown, chatId: string) => boolean;
42
+ }): boolean;
43
+ /**
44
+ * 检查发送者是否在允许列表中(通用)。
45
+ *
46
+ * `senderId` 是发送者账号名,`senderName` 是昵称(姓名)——两者都是该发送者的可读标识,
47
+ * 所以 `allowFrom` 里写账号名或写昵称都命中;`*` 放行全部。
48
+ */
49
+ export declare function isSenderAllowed(senderId: string, senderName?: string, allowFrom?: string[]): boolean;
@@ -1,4 +1,6 @@
1
1
  import { CHANNEL_ID } from './const.js';
2
+ import { resolveYouDuAliases } from './utils.js';
3
+ import { findGroupConfigEntry, isGroupIdShape, resolveGroupReference } from './group-resolve.js';
2
4
 
3
5
  /*
4
6
  * Licensed under the MIT License.
@@ -6,23 +8,62 @@ import { CHANNEL_ID } from './const.js';
6
8
  // ============================================================================
7
9
  // 内部辅助函数
8
10
  // ============================================================================
9
- function resolveWeComGroupConfig(params) {
10
- const groups = params.cfg?.groups ?? {};
11
- const wildcard = groups["*"];
12
- const groupId = params.groupId?.trim();
13
- if (!groupId) {
14
- return undefined;
15
- }
16
- const direct = groups[groupId];
17
- if (direct) {
18
- return direct;
19
- }
20
- const lowered = groupId.toLowerCase();
21
- const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
22
- if (matchKey) {
23
- return groups[matchKey];
24
- }
25
- return wildcard;
11
+ function normalize(value) {
12
+ return String(value ?? "").trim();
13
+ }
14
+ function equalsIgnoreCase(left, right) {
15
+ return left.toLowerCase() === right.toLowerCase();
16
+ }
17
+ /**
18
+ * 一条发送者白名单条目是否命中该发送者。
19
+ *
20
+ * 可写:账号名、昵称(姓名)、数字 userId,也可加 `user:` / `account:` / `name:` 前缀消歧。
21
+ * 只做精确匹配(大小写不敏感回退),不做前缀/模糊匹配。
22
+ */
23
+ function senderEntryMatches(entry, senderId, senderName) {
24
+ const normalized = normalize(entry)
25
+ .replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "")
26
+ .trim();
27
+ if (!normalized)
28
+ return false;
29
+ const prefixed = /^(user|account|name):(.*)$/i.exec(normalized);
30
+ const kind = prefixed?.[1].toLowerCase();
31
+ const value = (prefixed ? prefixed[2] : normalized).trim();
32
+ if (!value)
33
+ return false;
34
+ const account = normalize(senderId);
35
+ const name = normalize(senderName);
36
+ if (kind === "name")
37
+ return Boolean(name) && equalsIgnoreCase(value, name);
38
+ if (kind === "user" || kind === "account")
39
+ return Boolean(account) && equalsIgnoreCase(value, account);
40
+ return (Boolean(account) && equalsIgnoreCase(value, account))
41
+ || (Boolean(name) && equalsIgnoreCase(value, name));
42
+ }
43
+ /**
44
+ * 一条白名单条目是否指向这个群。
45
+ *
46
+ * 条目可以是群 ID,也可以是群名/别名:先按 ID 形态直比,再做名称解析。
47
+ * 名称解析歧义(`ambiguous`)时视为**不匹配**——白名单是安全边界,宁可拒绝。
48
+ */
49
+ function allowEntryMatchesGroup(params) {
50
+ const entry = normalize(params.entry);
51
+ const groupId = normalize(params.groupId);
52
+ if (!entry || !groupId)
53
+ return false;
54
+ if (entry === groupId)
55
+ return true;
56
+ if (entry.toLowerCase() === groupId.toLowerCase())
57
+ return true;
58
+ if (isGroupIdShape(entry))
59
+ return false;
60
+ const resolution = resolveGroupReference({
61
+ accountId: params.accountId,
62
+ target: entry,
63
+ aliases: params.aliases,
64
+ groups: params.groups,
65
+ });
66
+ return resolution.status === "resolved" && resolution.groupId === groupId;
26
67
  }
27
68
  /**
28
69
  * 检查群组是否在允许列表中
@@ -40,18 +81,34 @@ function isYouDuGroupAllowed(params) {
40
81
  if (normalizedAllowFrom.includes("*")) {
41
82
  return true;
42
83
  }
43
- const normalizedGroupId = params.groupId.trim();
44
- return normalizedAllowFrom.some((entry) => entry === normalizedGroupId || entry.toLowerCase() === normalizedGroupId.toLowerCase());
84
+ return normalizedAllowFrom.some((entry) => allowEntryMatchesGroup({
85
+ entry,
86
+ groupId: params.groupId,
87
+ accountId: params.accountId,
88
+ groups: params.groups,
89
+ aliases: params.aliases,
90
+ }));
45
91
  }
46
92
  /**
47
- * 检查群组内发送者是否在允许列表中
93
+ * 检查群组内发送者是否在允许列表中。
94
+ *
95
+ * 条目可以是发送者的账号名或昵称(姓名);两者都是标识该发送者的名字,
96
+ * 所以白名单里写哪个都生效。只做精确匹配(大小写不敏感)。
48
97
  */
49
98
  function isGroupSenderAllowed(params) {
50
- const { senderId, groupId, youduConfig } = params;
51
- const groupConfig = resolveWeComGroupConfig({
52
- cfg: youduConfig,
99
+ const { senderId, senderName, groupId, groups } = params;
100
+ const matched = findGroupConfigEntry({
53
101
  groupId,
102
+ accountId: params.accountId,
103
+ groups,
104
+ aliases: params.aliases,
54
105
  });
106
+ if (matched.ambiguous) {
107
+ // 两个群同名且都配了发送者白名单:无法判定该用哪一份,拒绝。
108
+ params.runtime?.log?.(`[youdu] Group ${groupId} matches multiple configured group names; denying sender check`);
109
+ return false;
110
+ }
111
+ const groupConfig = matched.entry ?? groups?.["*"];
55
112
  const perGroupSenderAllowFrom = (groupConfig?.allowFrom ?? []).map((v) => String(v));
56
113
  if (perGroupSenderAllowFrom.length === 0) {
57
114
  return true;
@@ -59,10 +116,7 @@ function isGroupSenderAllowed(params) {
59
116
  if (perGroupSenderAllowFrom.includes("*")) {
60
117
  return true;
61
118
  }
62
- return perGroupSenderAllowFrom.some((entry) => {
63
- const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
64
- return normalized === senderId || normalized === `user:${senderId}`;
65
- });
119
+ return perGroupSenderAllowFrom.some((entry) => senderEntryMatches(entry, senderId, senderName));
66
120
  }
67
121
  // ============================================================================
68
122
  // 公开 API
@@ -73,14 +127,25 @@ function isGroupSenderAllowed(params) {
73
127
  */
74
128
  function checkGroupPolicy(params) {
75
129
  const { chatId, senderId, account, config, runtime } = params;
130
+ const accountId = params.accountId ?? account.accountId;
76
131
  const youduConfig = (config.channels?.[CHANNEL_ID] ?? {});
132
+ const aliases = resolveYouDuAliases(config, accountId);
133
+ const groups = params.groups ?? (account.config.groups ?? youduConfig.groups ?? {});
77
134
  const defaultGroupPolicy = config.channels?.[CHANNEL_ID]?.groupPolicy;
78
135
  const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
79
- const groupAllowFrom = youduConfig.groupAllowFrom ?? [];
136
+ // 账号级与通道级取并集:两者都配时不会因为"账号级优先"而丢掉通道级白名单,
137
+ // 同时账号级条目自然生效(与 README 的"账号级优先"不冲突——并集下账号级永远不会被挡住)。
138
+ const groupAllowFrom = [
139
+ ...(account.config.groupAllowFrom ?? []),
140
+ ...(youduConfig.groupAllowFrom ?? []),
141
+ ];
80
142
  const groupAllowed = isYouDuGroupAllowed({
81
143
  groupPolicy,
82
144
  allowFrom: groupAllowFrom,
83
145
  groupId: chatId,
146
+ accountId,
147
+ groups,
148
+ aliases,
84
149
  });
85
150
  if (!groupAllowed) {
86
151
  runtime.log?.(`[youdu] Group ${chatId} not allowed (groupPolicy=${groupPolicy})`);
@@ -88,8 +153,12 @@ function checkGroupPolicy(params) {
88
153
  }
89
154
  const senderAllowed = isGroupSenderAllowed({
90
155
  senderId,
156
+ senderName: params.senderName,
91
157
  groupId: chatId,
92
- youduConfig,
158
+ accountId,
159
+ groups,
160
+ aliases,
161
+ runtime,
93
162
  });
94
163
  if (!senderAllowed) {
95
164
  runtime.log?.(`[youdu] Sender ${senderId} not in group ${chatId} sender allowlist`);
@@ -98,16 +167,39 @@ function checkGroupPolicy(params) {
98
167
  return { allowed: true };
99
168
  }
100
169
  /**
101
- * 检查发送者是否在允许列表中(通用)
170
+ * 解析该群生效的 `requireMention`(`groups.<群 ID 或群名>` 优先,其次 `"*"`,默认 true)。
102
171
  */
103
- function isSenderAllowed(senderId, allowFrom) {
172
+ function resolveGroupRequireMentionFor(params) {
173
+ const resolvedAccountId = params.accountId ?? "";
174
+ const youduConfig = (params.config.channels?.[CHANNEL_ID] ?? {});
175
+ const groups = params.groups ?? youduConfig.groups ?? {};
176
+ const matched = findGroupConfigEntry({
177
+ groupId: params.chatId,
178
+ accountId: resolvedAccountId,
179
+ groups,
180
+ aliases: resolveYouDuAliases(params.config, resolvedAccountId),
181
+ });
182
+ if (matched.ambiguous)
183
+ return true;
184
+ if (matched.entry && typeof matched.entry === "object") {
185
+ const value = matched.entry.requireMention;
186
+ if (typeof value === "boolean")
187
+ return value;
188
+ }
189
+ // 没命中具体条目时按纯函数规则处理(`"*"` → 默认 true)。
190
+ return params.fallback ? params.fallback(groups, params.chatId) : true;
191
+ }
192
+ /**
193
+ * 检查发送者是否在允许列表中(通用)。
194
+ *
195
+ * `senderId` 是发送者账号名,`senderName` 是昵称(姓名)——两者都是该发送者的可读标识,
196
+ * 所以 `allowFrom` 里写账号名或写昵称都命中;`*` 放行全部。
197
+ */
198
+ function isSenderAllowed(senderId, senderName, allowFrom = []) {
104
199
  if (allowFrom.includes("*")) {
105
200
  return true;
106
201
  }
107
- return allowFrom.some((entry) => {
108
- const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
109
- return normalized === senderId || normalized === `user:${senderId}`;
110
- });
202
+ return allowFrom.some((entry) => senderEntryMatches(entry, senderId, senderName));
111
203
  }
112
204
 
113
- export { checkGroupPolicy, isSenderAllowed };
205
+ export { checkGroupPolicy, isSenderAllowed, resolveGroupRequireMentionFor };