@zaofan/dsh-qqbot 1.7.9 → 1.8.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.
@@ -95,6 +95,32 @@ export declare function syncContextTools(agentCtx: unknown, shouldHave: boolean,
95
95
  * 幂等靠 globalThis 记句柄:跨热刷(模块重新 import)依然有效,避免热刷后重复注册被宿主判重名。
96
96
  * ⚠️ 可见性不在这里决定 —— 交给 {@link restrictContextTools} 按会话剪枝。
97
97
  */
98
+ /**
99
+
100
+ * 把 `expose: 'all'` 的扩展工具注册到**插件根作用域**(2026-10-10 主人要的"暴露给全部会话")。
101
+
102
+ *
103
+
104
+ * 为什么必须挂在这一层:扩展工具平时由 session-manager 在**每个 QQ 会话**上用 `agentCtx` 注册,
105
+
106
+ * 而 web / 终端等**非 QQ 会话不走那条链** ⇒ 压根看不到它们。想让它们跨通道,
107
+
108
+ * 只能挂到插件根 —— 宿主会让所有会话继承插件作用域的工具(`context_*` 三工具当年也是为此才改成全局)。
109
+
110
+ *
111
+
112
+ * 命名:`<name>@<人设 id>`,并且**每个账号实例各注册一份**(实例各有自己的 preset / appId / 账号数据);
113
+
114
+ * 执行时由注册名里的 tag 反查账号包(见 buildWorkspaceQqPack)—— **名字即归属**。
115
+
116
+ *
117
+
118
+ * 幂等:句柄存 globalThis(跨热刷可见),已注册过的 `name@tag` 不重复注册。
119
+
120
+ * fail-soft:拿不到 tools 服务 / 没有实例 / 目录读不到,一律静默跳过,绝不影响主流程。
121
+
122
+ */
123
+ export declare function ensureExtToolsGlobal(globalCtx: unknown): void;
98
124
  export declare function ensureContextToolsGlobal(globalCtx: unknown): void;
99
125
  /**
100
126
  * 按会话**实时剪枝**三个上下文工具的可见性(2026-10-09,主人要的"实时变更")。
@@ -1 +1 @@
1
- {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"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"}
@@ -111,6 +111,183 @@ export const inject = ['tools'];
111
111
  * 防止 setup 未执行/竞态导致"不是 QQ 会话"误判(线上踩坑: 重启后 setup 未跑到)。
112
112
  */
113
113
  let channelBridges = [];
114
+ /** 全局懒扫节流(2026-10-10):ensureExtToolsGlobal 挂在"每 step 都触发"的钩子上,别每次都扫盘 */
115
+ let lastExtGlobAt = 0;
116
+ /**
117
+ * expose:'all' 全局注册的**专用诊断**(2026-10-10 排查用)。
118
+ * ⚠️ 无条件写:不走 diagWrite/diagLog 那套(那套默认关,害我以为"没跑",其实是"没记")。
119
+ * 行程:~/.dsh/qqbot-extglob.log —— 定位完可整段删。
120
+ */
121
+ function extGlobDiag(line) {
122
+ // ★ 2026-10-10 主人提醒(人家的疏忽):**不该再有"无条件写自有文件"的日志** ——
123
+ // 别的用户不需要它,而且没有轮转会一直吃盘(1.7.3 就为这个还过债)。
124
+ // 现在改走统一诊断底座:受设置页 ⑤「诊断日志落盘」开关控制(**默认关**)、2MB 自动轮转、只留 1 份旧档。
125
+ // ⇒ 需要排查时把那个开关打开即可,平时一个字节都不写。
126
+ diagWrite('qqbot-extglob', line);
127
+ }
128
+ /** 工作区标识兜底:cwd 末级目录名(清洗后)。仅在实例没配 preset 时使用 */
129
+ function workspaceTagOf(cwd) {
130
+ const base = String(cwd ?? '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '';
131
+ const asciiOnly = base.replace(/[^A-Za-z0-9_-]+/g, '');
132
+ if (asciiOnly.length >= 2)
133
+ return asciiOnly.toLowerCase().slice(0, 24);
134
+ let hsh = 0;
135
+ const srcT = String(cwd ?? base);
136
+ for (let i = 0; i < srcT.length; i++)
137
+ hsh = (hsh * 31 + srcT.charCodeAt(i)) | 0;
138
+ return 'ws' + Math.abs(hsh).toString(36).slice(0, 6);
139
+ }
140
+ /** 某实例的标识:优先**人设 id**(config.preset),没配才回落工作区目录名 */
141
+ function instanceTagOfManager(m) {
142
+ const cfg = m?.config;
143
+ const p = String(cfg?.preset ?? '').trim();
144
+ return p || workspaceTagOf(m?.cwd);
145
+ }
146
+ /** 按标识找人设实例的通道桥(先查全局桥,再退会话注册表) */
147
+ function findChannelByTag(tag) {
148
+ if (!tag)
149
+ return undefined;
150
+ for (const b of channelBridges) {
151
+ if (b?.manager && instanceTagOfManager(b.manager) === tag)
152
+ return { manager: b.manager, sender: b.sender };
153
+ }
154
+ try {
155
+ for (const m of managersOf()) {
156
+ if (instanceTagOfManager(m) === tag) {
157
+ const b = channelBridges.find((x) => x.manager === m);
158
+ return { manager: m, sender: b?.sender };
159
+ }
160
+ }
161
+ }
162
+ catch { /* ignore */ }
163
+ return undefined;
164
+ }
165
+ /**
166
+ * 构造"人设 / 工作区的 QQ 账号包"(expose:'all' 专用;非 QQ 会话的唯一入口)。
167
+ * @param tag 人设 id(如 whale)
168
+ * @param dataRoot 插件数据根(群台账在这里)
169
+ */
170
+ function buildWorkspaceQqPack(tag, dataRoot) {
171
+ const hit = findChannelByTag(tag);
172
+ if (!hit)
173
+ return undefined;
174
+ const mgr = hit.manager;
175
+ // ⚠️ 2026-10-10:**别从桥里拿 sender** —— channelBridges 未必被填充(实测是空的),
176
+ // 真正的 sender 在 manager 上:installChannelSender() → this.channelSender。
177
+ const mgrSender = mgr?.channelSender;
178
+ extGlobDiag('pack tag=' + tag + ' bridgeSender=' + (hit.sender ? 'Y' : 'N') + ' mgrSender=' + (mgrSender ? 'Y' : 'N'));
179
+ const sender = (hit.sender ?? mgrSender);
180
+ const root = String(mgr?.dataRoot ?? dataRoot ?? '');
181
+ const toTarget = (scope, id) => ({
182
+ scope: scope === 'c2c' ? 'c2c' : 'group', targetId: id,
183
+ });
184
+ // 已知聊天目标 = 群注册表(groups.json) + 各实例活跃会话;同一目标按"最新者胜"
185
+ const known = new Map();
186
+ const add = (scope, id, name, lastAt = 0) => {
187
+ if (!id)
188
+ return;
189
+ const key = scope + ':' + id;
190
+ const cur = known.get(key);
191
+ if (!cur) {
192
+ known.set(key, { scope, id, name, lastAt });
193
+ return;
194
+ }
195
+ if (lastAt > cur.lastAt) {
196
+ cur.lastAt = lastAt;
197
+ if (name)
198
+ cur.name = name;
199
+ return;
200
+ }
201
+ if (!cur.name && name)
202
+ cur.name = name;
203
+ };
204
+ try {
205
+ const reg = JSON.parse(readFileSync(groupRegistryPath(root), 'utf8'));
206
+ for (const [gid, v] of Object.entries(reg ?? {}))
207
+ add('group', gid, String(v?.name ?? ''), Number(v?.lastAt ?? 0));
208
+ }
209
+ catch { /* 没有注册表就跳过 */ }
210
+ try {
211
+ for (const m of managersOf()) {
212
+ try {
213
+ for (const s of (m.listSessions?.() ?? [])) {
214
+ // ⚠️ listSessions() 只给 scope/peerId(没有 name)→ 群名靠 groups.json 注册表补
215
+ add(s.scope === 'c2c' ? 'c2c' : 'group', String(s.peerId ?? ''), '');
216
+ }
217
+ }
218
+ catch { /* 单个实例失败无妨 */ }
219
+ }
220
+ }
221
+ catch { /* ignore */ }
222
+ const all = [...known.values()].sort((a, b) => b.lastAt - a.lastAt);
223
+ return {
224
+ preset: String(mgr?.config?.preset ?? tag),
225
+ tag,
226
+ cwd: String(mgr?.cwd ?? ''),
227
+ dataRoot: root,
228
+ appId: mgr?.config?.appId,
229
+ appSecret: mgr?.config?.appSecret,
230
+ owners: mgr?.config?.owners ?? [],
231
+ groups: all.filter((x) => x.scope === 'group').slice(0, 200).map((x) => ({ gid: x.id, name: x.name, lastAt: x.lastAt })),
232
+ c2c: all.filter((x) => x.scope === 'c2c').slice(0, 200).map((x) => ({ openid: x.id, name: x.name, lastAt: x.lastAt })),
233
+ sender,
234
+ /**
235
+ * 发文本到指定目标:sendText('group', openid, '文本') / sendText('c2c', openid, '文本')
236
+ * ★ 2026-10-10 主人定:先走被动通道(带会话的最近 msgId),失败自动转主动。
237
+ * ① 查目标会话最近一条入站消息的 msgId
238
+ * (manager.getSessionRecord(scope, peerId).replyTarget.msgId,每次入站都会刷新)
239
+ * ⇒ 带上它发 = 被动回复(不消耗主动消息配额、QQ 也不吞);
240
+ * ② 没有窗口 / msg_id 已过期 / 被动配额用尽 ⇒ 去掉 msgId 重发 = 主动(QQ 允许的兜底)。
241
+ * 返回据实;两条路径都有诊断留痕(~/.dsh/qqbot-extglob.log)。
242
+ */
243
+ sendText: async (scope, id, text) => {
244
+ if (!id)
245
+ return false;
246
+ const snd = sender;
247
+ if (!snd)
248
+ return false;
249
+ const target = toTarget(scope, id);
250
+ let passiveMsgId = '';
251
+ try {
252
+ const rec = mgr?.getSessionRecord?.(target.scope, id);
253
+ passiveMsgId = String(rec?.replyTarget?.msgId ?? '');
254
+ }
255
+ catch { /* ignore */ }
256
+ if (passiveMsgId && typeof snd.sendMarkdown === 'function') {
257
+ try {
258
+ await snd.sendMarkdown({ ...target, msgId: passiveMsgId }, String(text));
259
+ extGlobDiag('send PASSIVE ok -> ' + target.scope + ':' + id);
260
+ return true;
261
+ }
262
+ catch (e) {
263
+ extGlobDiag('send PASSIVE fail, 转主动 -> ' + target.scope + ':' + id + ' :: ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 140));
264
+ }
265
+ }
266
+ try {
267
+ if (typeof snd.sendText === 'function') {
268
+ await snd.sendText(target, String(text));
269
+ extGlobDiag('send ACTIVE ok -> ' + target.scope + ':' + id);
270
+ return true;
271
+ }
272
+ if (typeof snd.sendMarkdown === 'function') {
273
+ await snd.sendMarkdown(target, String(text));
274
+ extGlobDiag('send ACTIVE ok(md) -> ' + target.scope + ':' + id);
275
+ return true;
276
+ }
277
+ }
278
+ catch (e) {
279
+ extGlobDiag('send ACTIVE fail -> ' + target.scope + ':' + id + ' :: ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 140));
280
+ return false;
281
+ }
282
+ return false;
283
+ },
284
+ sendMarkdown: (scope, id, md) => (typeof sender?.sendMarkdown === 'function' && id ? Promise.resolve(sender.sendMarkdown(toTarget(scope, id), String(md))).then(() => true).catch(() => false) : Promise.resolve(false)),
285
+ sendImage: (scope, id, src) => (typeof sender?.sendMedia === 'function' && id ? Promise.resolve(sender.sendMedia(toTarget(scope, id), 'image', src)).then(() => true).catch(() => false) : Promise.resolve(false)),
286
+ apiCall: typeof mgr?.groupAdmin?.apiCall === 'function'
287
+ ? (m, p, b) => mgr.groupAdmin.apiCall(m, p, b)
288
+ : undefined,
289
+ };
290
+ }
114
291
  /** 全局「智能判断」开关(由 session-manager 初始化时注入;照 setChannelBridge 同款做法) */
115
292
  let ctxSmartGlobal = false;
116
293
  export function setContextlessSmartGlobal(v) { ctxSmartGlobal = v === true; }
@@ -2272,6 +2449,21 @@ export async function apply(ctx) {
2272
2449
  diag(`注册失败: ${t.name} → ${msg}`);
2273
2450
  }
2274
2451
  }
2452
+ // ══════════════════════════════════════════════════════════════════════════
2453
+ // expose:'all' 支撑(2026-10-10 主人定)—— 扩展工具的"跨通道"能力
2454
+ // ══════════════════════════════════════════════════════════════════════════
2455
+ // 背景:扩展工具的执行体一直靠 `findSessionRec(当前 agent)` 拿 manager/sender,
2456
+ // 而这条链**只在 QQ 会话里成立** —— 在 web / 终端会话里调用,manager 是空的,
2457
+ // 工具就变成空壳(拿不到 appId,也发不出消息)。
2458
+ // 做法:工具文件里声明 `expose: 'all'` 之后 ——
2459
+ // ① 注册名带上**人设 id**(`<name>@<preset>`,如 `send_texts@whale`)标明归属;
2460
+ // ② 执行时注入 `env.qq` = 该人设实例的"QQ 账号包":
2461
+ // { preset, appId, appSecret, owners, groups[], c2c[], sender,
2462
+ // sendText / sendMarkdown / sendImage, apiCall }
2463
+ // ⇒ 非 QQ 会话也能把消息发进**指定的群 / 私聊**。
2464
+ // 不声明 expose 的工具**行为完全不变**(向后兼容)。
2465
+ // ⚠️ 安全性:这些参数只交给本机 `.qqbot-extensions/tools/*.mjs`(主人自己写的代码),
2466
+ // 与内置工具同权限;别把它当成"对外暴露的口子"。
2275
2467
  // ── P4.2 用户扩展工具: 扫描 {cwd}/.qqbot-extensions/tools/ 并注册 ──
2276
2468
  // 扩展文件 run(args, env) 可拿: { cwd, manager, sender, replyTarget, logger }
2277
2469
  // (调用方 session-manager 已 await mountChannelTools, 此处 await 加载不影响时序)。
@@ -2329,19 +2521,32 @@ export async function apply(ctx) {
2329
2521
  return m;
2330
2522
  })();
2331
2523
  for (const def of defs) {
2524
+ // ★ expose:'all' 的工具**不在这里注册** —— 交给 ensureExtToolsGlobal 挂到插件根
2525
+ // (只有挂到那一层,web / 终端等非 QQ 会话才继承得到;在这儿重复注册的话,
2526
+ // QQ 会话会同时看到「带后缀」和「不带后缀」两套工具)
2527
+ if (def.expose === 'all')
2528
+ continue;
2332
2529
  try {
2530
+ // 注:expose:'all' 的工具已在循环开头 continue(它们由 ensureExtToolsGlobal 全局注册)。
2531
+ // 走到这里的都是"只服务 QQ 会话"的工具 ⇒ 名字保持原名,不带 @人设id
2532
+ const scopedName = def.name;
2533
+ // ⚠️ 兜底闸门(同上):扩展工具作者若写了中文名,这里直接不注册 —— 别把整条链路带崩
2534
+ if (!/^[a-zA-Z0-9_-]+$/.test(scopedName)) {
2535
+ extDiag('跳过非法工具名(中文等): ' + scopedName);
2536
+ continue;
2537
+ }
2333
2538
  // 同一个 ctx 再来一遍(热刷)= 更新已有工具:先摘掉我们自己上次注册的那份
2334
- const prev = myDisposers.get(def.name);
2539
+ const prev = myDisposers.get(scopedName);
2335
2540
  if (prev) {
2336
2541
  try {
2337
2542
  prev();
2338
2543
  }
2339
2544
  catch { /* 旧句柄已失效,忽略 */ }
2340
- myDisposers.delete(def.name);
2545
+ myDisposers.delete(scopedName);
2341
2546
  extDiag(`热替换: 先注销旧 ${def.name}`);
2342
2547
  }
2343
2548
  const tool = defineTool({
2344
- name: def.name,
2549
+ name: scopedName,
2345
2550
  description: def.description + ' (用户扩展工具)',
2346
2551
  parameters: def.inputSchema,
2347
2552
  output: {
@@ -2402,6 +2607,8 @@ export async function apply(ctx) {
2402
2607
  if (!(k in env))
2403
2608
  env[k] = v;
2404
2609
  env.caps = caps;
2610
+ // 注:expose:'all' 的工具带 env.qq 的那份在 ensureExtToolsGlobal 里全局注册
2611
+ // (非 QQ 会话只有继承到那一份才能用),这里不再重复注入。
2405
2612
  }
2406
2613
  catch (err) {
2407
2614
  extDiag(`能力包构造失败(不影响工具本身): ${err instanceof Error ? err.message : String(err)}`);
@@ -2420,8 +2627,8 @@ export async function apply(ctx) {
2420
2627
  const dispose = ctx.tools.register(tool);
2421
2628
  // 收下 disposer —— 下次(热刷/重装配)靠它做同名替换,不然只能重启宿主
2422
2629
  if (typeof dispose === 'function')
2423
- myDisposers.set(def.name, dispose);
2424
- ctx.logger?.info?.(`[channel-tools] 已注册扩展工具: ${def.name}`);
2630
+ myDisposers.set(scopedName, dispose);
2631
+ ctx.logger?.info?.(`[channel-tools] 已注册扩展工具: ${scopedName}`);
2425
2632
  diag(`扩展工具注册成功: ${def.name}`);
2426
2633
  extDiag(`注册成功: ${def.name}`);
2427
2634
  }
@@ -2983,13 +3190,189 @@ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2983
3190
  // 状态没变就一根手指都不动(不产生任何变更通知)。
2984
3191
  // =========================================================================
2985
3192
  /** 三个上下文工具的名字(restrict 用;必须与 ctxToolsRef() 里的 name 完全一致) */
2986
- const CTX_TOOL_NAMES = ['context_memo', 'context_compact', 'context_drop'];
3193
+ /** 需要"按会话剪枝"的工具名(restrict 用)。
3194
+ * ⚠️ 2026-10-10:`qq_group_admin` 也在这里 —— 它跟 context_* 一样是**QQ 通道专属**,
3195
+ * 全局注册后若不在非 QQ 会话里 deny 掉,会白占每个会话的 token(主人要求剪枝覆盖非 QQ 会话)。 */
3196
+ const CTX_TOOL_NAMES = ['context_memo', 'context_compact', 'context_drop', 'qq_group_admin'];
2987
3197
  /**
2988
3198
  * 把"三个上下文工具 + QQ 群管理工具"**全局注册一次、永不注销**。
2989
3199
  *
2990
3200
  * 幂等靠 globalThis 记句柄:跨热刷(模块重新 import)依然有效,避免热刷后重复注册被宿主判重名。
2991
3201
  * ⚠️ 可见性不在这里决定 —— 交给 {@link restrictContextTools} 按会话剪枝。
2992
3202
  */
3203
+ /**
3204
+
3205
+ * 把 `expose: 'all'` 的扩展工具注册到**插件根作用域**(2026-10-10 主人要的"暴露给全部会话")。
3206
+
3207
+ *
3208
+
3209
+ * 为什么必须挂在这一层:扩展工具平时由 session-manager 在**每个 QQ 会话**上用 `agentCtx` 注册,
3210
+
3211
+ * 而 web / 终端等**非 QQ 会话不走那条链** ⇒ 压根看不到它们。想让它们跨通道,
3212
+
3213
+ * 只能挂到插件根 —— 宿主会让所有会话继承插件作用域的工具(`context_*` 三工具当年也是为此才改成全局)。
3214
+
3215
+ *
3216
+
3217
+ * 命名:`<name>@<人设 id>`,并且**每个账号实例各注册一份**(实例各有自己的 preset / appId / 账号数据);
3218
+
3219
+ * 执行时由注册名里的 tag 反查账号包(见 buildWorkspaceQqPack)—— **名字即归属**。
3220
+
3221
+ *
3222
+
3223
+ * 幂等:句柄存 globalThis(跨热刷可见),已注册过的 `name@tag` 不重复注册。
3224
+
3225
+ * fail-soft:拿不到 tools 服务 / 没有实例 / 目录读不到,一律静默跳过,绝不影响主流程。
3226
+
3227
+ */
3228
+ export function ensureExtToolsGlobal(globalCtx) {
3229
+ // 节流:本函数挂在 agent/pre-step(**每个 step** 都触发)—— 15 秒内不重复扫盘;
3230
+ // 幂等保证:即便节流窗口外重复进入,已注册的 name@tag 也会被跳过。
3231
+ extGlobDiag('enter ctxType=' + (globalCtx === null ? 'null' : typeof globalCtx));
3232
+ const nowMs = Date.now();
3233
+ if (nowMs - lastExtGlobAt < 15000)
3234
+ return;
3235
+ lastExtGlobAt = nowMs;
3236
+ extGlobDiag('pass-throttle');
3237
+ void (async () => {
3238
+ try {
3239
+ if (!globalCtx || typeof globalCtx !== 'object')
3240
+ return;
3241
+ // ★ 2026-10-10 关键修复:必须拿**插件根 ctx**。
3242
+ // 证据:主人日志里 context_* + qq_group_admin + 人家的两个工具被**一起移除** ——
3243
+ // 说明它们其实都挂在**会话作用域**上(会话一换就没了),也就是说 this.ctx 不是根。
3244
+ // cordis 的 ctx.root 才是根作用域;拿不到就退回原值(行为不变)。
3245
+ const rootCtx = ((globalCtx?.root) ?? globalCtx);
3246
+ extGlobDiag('root=' + (rootCtx === globalCtx ? 'same-as-passed' : 'DIFFERS'));
3247
+ const gctx = rootCtx;
3248
+ let toolsObj;
3249
+ try {
3250
+ toolsObj = gctx.get?.('tools');
3251
+ }
3252
+ catch { /* ignore */ }
3253
+ if (!toolsObj || typeof toolsObj.register !== 'function') {
3254
+ extGlobDiag('FAIL 拿不到 tools 服务');
3255
+ extDiag('[extglob] 拿不到 tools 服务, 跳过');
3256
+ return;
3257
+ }
3258
+ // 实例清单:每个账号实例注册一份(tag = 人设 id)
3259
+ const insts = [];
3260
+ try {
3261
+ for (const m of managersOf()) {
3262
+ const mm = m;
3263
+ const tag = instanceTagOfManager(mm);
3264
+ if (tag && mm.dataRoot)
3265
+ insts.push({ tag, dataRoot: String(mm.dataRoot), cwd: String(mm.cwd ?? '') });
3266
+ }
3267
+ }
3268
+ catch { /* ignore */ }
3269
+ extGlobDiag('instances=' + insts.length + ' :: ' + insts.map((x) => x.tag).join(','));
3270
+ if (!insts.length) {
3271
+ extGlobDiag('FAIL 暂无实例');
3272
+ extDiag('[extglob] 暂无实例, 跳过');
3273
+ return;
3274
+ }
3275
+ const g = globalThis;
3276
+ if (!g.__qqbotExtToolsGlobal)
3277
+ g.__qqbotExtToolsGlobal = new Map();
3278
+ const reg = g.__qqbotExtToolsGlobal;
3279
+ for (const inst of insts) {
3280
+ let defs = [];
3281
+ try {
3282
+ defs = await loadExtensionTools(inst.dataRoot, (gctx.logger ?? console));
3283
+ }
3284
+ catch {
3285
+ defs = [];
3286
+ }
3287
+ const exposed = defs.filter((d) => d.expose === 'all');
3288
+ extGlobDiag('inst=' + inst.tag + ' defs=' + defs.length + ' exposeAll=' + exposed.length + ' names=' + defs.map((d) => d.name + (d.expose === 'all' ? '*' : '')).join('|'));
3289
+ if (!exposed.length)
3290
+ continue;
3291
+ for (const def of exposed) {
3292
+ // ⚠️ 2026-10-10 事故真凶:分隔符必须在 [a-zA-Z0-9_-] 之内 ——
3293
+ // `@` 不在 LLM API 允许的工具名字符集里(^[a-zA-Z0-9_-]+$),
3294
+ // 一旦注册进去就是"每一轮请求都被拒"(机器人整体瘫痪)。故改用 `__`。
3295
+ const full = def.name + '__' + inst.tag;
3296
+ // ⚠️ 兜底闸门:工具名只允许 [a-zA-Z0-9_-](LLM API 硬约束)——
3297
+ // 非法名一旦注册进工具表,**每一轮请求都会被 API 整轮拒绝**(2026-10-10 事故)。
3298
+ if (!/^[a-zA-Z0-9_-]+$/.test(full)) {
3299
+ extGlobDiag('SKIP illegal global name: ' + full);
3300
+ continue;
3301
+ }
3302
+ if (reg.has(full))
3303
+ continue;
3304
+ try {
3305
+ const tool = defineTool({
3306
+ name: full,
3307
+ description: def.description + ' (用户扩展工具 · ' + inst.tag + ')',
3308
+ parameters: def.inputSchema,
3309
+ output: {
3310
+ schema: {
3311
+ type: 'object', additionalProperties: false,
3312
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
3313
+ },
3314
+ render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : ('失败: ' + v.msg) }],
3315
+ },
3316
+ async execute(args, exec) {
3317
+ // 账号包:按注册名里的 tag 反查(这就是"名字即归属")
3318
+ let pack;
3319
+ try {
3320
+ pack = buildWorkspaceQqPack(inst.tag, inst.dataRoot);
3321
+ }
3322
+ catch {
3323
+ pack = undefined;
3324
+ }
3325
+ const env = {
3326
+ cwd: inst.cwd || inst.dataRoot,
3327
+ exec,
3328
+ ctx: globalCtx,
3329
+ logger: gctx.logger,
3330
+ qq: pack,
3331
+ };
3332
+ // 若这一次调用恰好来自 QQ 会话,则会话级上下文优先(sender/replyTarget/manager 用当前会话的)
3333
+ try {
3334
+ const sess = findSessionRec(channelOf(exec), exec);
3335
+ if (sess) {
3336
+ env.manager = sess.ch.manager;
3337
+ env.sender = sess.ch.sender;
3338
+ env.replyTarget = sess.rec.replyTarget;
3339
+ }
3340
+ else if (pack) {
3341
+ env.sender = pack.sender;
3342
+ }
3343
+ }
3344
+ catch { /* ignore */ }
3345
+ try {
3346
+ const r = await def.run(args, env);
3347
+ if (r && typeof r === 'object')
3348
+ return { ok: r.ok === true, msg: String(r.msg ?? 'done') };
3349
+ return { ok: true, msg: r === undefined || r === null ? 'done' : String(r) };
3350
+ }
3351
+ catch (err) {
3352
+ return { ok: false, msg: err instanceof Error ? err.message : String(err) };
3353
+ }
3354
+ },
3355
+ });
3356
+ const d = toolsObj.register.call(toolsObj, tool);
3357
+ if (typeof d === 'function')
3358
+ reg.set(full, d);
3359
+ extGlobDiag('OK registered ' + full);
3360
+ extDiag('[extglob] 全局注册 ' + full);
3361
+ gctx.logger?.info?.('[channel-tools] 扩展工具已暴露给全部会话: ' + full);
3362
+ }
3363
+ catch (e) {
3364
+ extGlobDiag('FAIL register ' + full + ' :: ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 200));
3365
+ extDiag('[extglob] 注册失败 ' + full + ': ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 160));
3366
+ }
3367
+ }
3368
+ }
3369
+ }
3370
+ catch (e) {
3371
+ extGlobDiag('EXCEPTION ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 200));
3372
+ extDiag('[extglob] 异常: ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 160));
3373
+ }
3374
+ })();
3375
+ }
2993
3376
  export function ensureContextToolsGlobal(globalCtx) {
2994
3377
  try {
2995
3378
  if (!globalCtx || typeof globalCtx !== 'object')