@zaofan/dsh-qqbot 1.7.10 → 1.8.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
@@ -352,6 +352,32 @@ dsh: disabling profile plugin row "mcp-chrome": Plugin ... is incompatible with
352
352
 
353
353
  ---
354
354
 
355
+ ## 🧩 扩展工具:自己写 QQ 工具,还能"跨通道"(v1.8.0+)
356
+
357
+ 把 `.mjs` 放进 `{dataRoot}/.qqbot-extensions/tools/`,AI 就能调用它(写完发 `/tools-reload` 即生效,无需重启)。
358
+ **默认**:扩展工具**只在 QQ 会话里存在**——别处的会话完全看不到它,所以也不占一点 token。
359
+
360
+ **加一行 `expose: 'all'`**,它就对**所有会话可见**(web 对话、终端…),并在那边**直接操作 QQ**:
361
+
362
+ ```js
363
+ export default {
364
+ name: 'qq_send',
365
+ expose: 'all',
366
+ description: '查 QQ 聊天对象 / 向群或私聊发消息',
367
+ inputSchema: { action: { type: 'string' }, target: { type: 'string' }, text: { type: 'string' } },
368
+ run: async (args, env) => {
369
+ const qq = env.qq; // 账号包:appId / 群与私聊清单 / sender / sendText / apiCall…
370
+ return { ok: true, msg: (await qq.sendText('group', args.target, args.text)) ? '已发送 ✓' : '发送失败' };
371
+ },
372
+ };
373
+ ```
374
+
375
+ - 注册名会带上**人设 id**(如 `qq_send__whale-girl`),归属一眼可见;
376
+ ⚠️ **工具名只能用 `[a-zA-Z0-9_-]`** —— 用 `@` 之类的字符会被 LLM API **整轮拒绝**(会让机器人全面瘫痪)。插件现在会**直接拒绝注册**这类名字,不会再把整条链路带崩。
377
+ - 发送**被动优先、失败转主动**:先拿目标会话最近的 `msgId` 走被动回复(不吃主动配额、也不被 QQ 吞),失败再去掉 `msgId` 重发。冷群没有被动窗口,会直接走主动。
378
+ - **新用户不用自己搬示例**:插件启动时会把自带的 `examples/ext-tools`、`examples/botplay` **只补不覆盖**地铺进你的扩展目录 —— 装上就有能跑的工具(如 `qq_send`)和卡片示例。
379
+
380
+ 细节见 **[用户手册](./docs/USER-GUIDE.md)** 的「自定义 QQ 工具」一节。
355
381
  ## 🔨 锤子面板:禁言 / 踢人(v1.6.3)
356
382
 
357
383
  dock 面板里的 **🔨 锤子**(原来叫「🔇 禁言」),下面分两页:
package/README_EN.md CHANGED
@@ -391,6 +391,31 @@ export default {
391
391
  ```
392
392
  Then run `/tools-reload` (or ask the AI to call the `tools_reload` tool). Commands need a host restart.
393
393
 
394
+ ### Cross-channel tools: `expose: 'all'` (v1.8.0+)
395
+
396
+ By default an extension tool exists **only in QQ conversations** (that is where the plugin mounts them — zero token cost anywhere else).
397
+ One extra line makes it **visible to every conversation** (web / terminal / any non-QQ channel) and able to **act on QQ** from there:
398
+
399
+ ```js
400
+ export default {
401
+ name: 'qq_send',
402
+ expose: 'all', // ← the switch
403
+ description: 'list QQ chats / send a message to a group or DM',
404
+ inputSchema: { action: { type: 'string' }, target: { type: 'string' }, text: { type: 'string' } },
405
+ run: async (args, env) => {
406
+ const qq = env.qq; // ← injected only for expose:'all'
407
+ return { ok: true, msg: (await qq.sendText('group', args.target, args.text)) ? 'sent ✓' : 'failed' };
408
+ },
409
+ };
410
+ ```
411
+
412
+ - **Registered name** becomes `<name>__<preset_id>` (e.g. `qq_send__whale-girl`), so you can tell which persona/account it belongs to.
413
+ ⚠️ **Tool names may only contain `[a-zA-Z0-9_-]`** — a character like `@` makes the LLM API reject the **whole request** (`Invalid 'tools[N].name'`). The plugin now validates first and simply **refuses** illegal names instead of breaking every turn.
414
+ - **`env.qq`** = that persona's account pack: `{ preset, appId, appSecret, owners, groups:[{gid,name,lastAt}], c2c:[{openid,name,lastAt}], sender, sendText(scope,id,text), sendMarkdown(...), sendImage(...), apiCall(method,path,body) }`.
415
+ - **Sending is passive-first**: `sendText()` looks up the target conversation's latest inbound `msgId` and replies with it (passive — no active-message quota, and QQ will not drop it); on failure it **retries without the msgId** (active fallback). A passive window only exists in conversations with **recent activity** — a cold group has none and will always go active.
416
+ - Registration happens **at plugin startup** (no need to wait for a QQ message), idempotent and fail-soft.
417
+ - **New users get examples for free**: at startup the plugin copies `examples/ext-tools/*.mjs` and `examples/botplay/*.mjs` into `{dataRoot}/.qqbot-extensions/{tools,botplay}/` — **only missing files**, never overwriting what you wrote.
418
+
394
419
  **For AI agents — you can write these yourself (no feature request needed):**
395
420
  - Card bodies are **plain markdown you write** (`#`, bold, `![alt](url)`, fenced code). This plugin has **no template engine — the model is the engine**. Cards with buttons go through a botplay event or the dock card editor (button callbacks must be registered host-side).
396
421
  - Extensions live in the **account data directory** (`.qqbot-extensions/`), never inside the plugin package → **upgrading or reinstalling the plugin never overwrites them**.
@@ -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,188 @@ 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
+ // 诊断(2026-10-10):被动窗口到底有没有 —— 排查"为何走了主动通道"用
255
+ try {
256
+ extGlobDiag('send window=' + (passiveMsgId ? ('Y/' + passiveMsgId.slice(0, 10)) : 'N') + ' rec=' + (rec ? 'Y' : 'N') + ' key=' + String(rec && rec.sessionKey || '') + ' -> ' + target.scope + ':' + id.slice(0, 12));
257
+ }
258
+ catch { /* ignore */ }
259
+ }
260
+ catch { /* ignore */ }
261
+ if (passiveMsgId && typeof snd.sendMarkdown === 'function') {
262
+ try {
263
+ await snd.sendMarkdown({ ...target, msgId: passiveMsgId }, String(text));
264
+ extGlobDiag('send PASSIVE ok -> ' + target.scope + ':' + id);
265
+ return true;
266
+ }
267
+ catch (e) {
268
+ extGlobDiag('send PASSIVE fail, 转主动 -> ' + target.scope + ':' + id + ' :: ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 140));
269
+ }
270
+ }
271
+ try {
272
+ if (typeof snd.sendText === 'function') {
273
+ await snd.sendText(target, String(text));
274
+ extGlobDiag('send ACTIVE ok -> ' + target.scope + ':' + id);
275
+ return true;
276
+ }
277
+ if (typeof snd.sendMarkdown === 'function') {
278
+ await snd.sendMarkdown(target, String(text));
279
+ extGlobDiag('send ACTIVE ok(md) -> ' + target.scope + ':' + id);
280
+ return true;
281
+ }
282
+ }
283
+ catch (e) {
284
+ extGlobDiag('send ACTIVE fail -> ' + target.scope + ':' + id + ' :: ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 140));
285
+ return false;
286
+ }
287
+ return false;
288
+ },
289
+ 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)),
290
+ 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)),
291
+ apiCall: typeof mgr?.groupAdmin?.apiCall === 'function'
292
+ ? (m, p, b) => mgr.groupAdmin.apiCall(m, p, b)
293
+ : undefined,
294
+ };
295
+ }
114
296
  /** 全局「智能判断」开关(由 session-manager 初始化时注入;照 setChannelBridge 同款做法) */
115
297
  let ctxSmartGlobal = false;
116
298
  export function setContextlessSmartGlobal(v) { ctxSmartGlobal = v === true; }
@@ -853,6 +1035,8 @@ export async function apply(ctx) {
853
1035
  const replyGateTool = defineTool({
854
1036
  name: 'reply_gate',
855
1037
  description: '回复闸门: 开始回复前先调用它, 决定本回合开口还是静默。传 reply:true → 正常继续, 按当前对话决定说什么; 传 reply:false → 本回合立即静默终止, 你不会再输出任何内容(这条消息就像没发生过)。选 true 还是 false 由你根据当前对话自己判断: 觉得该回应就 true, 觉得安静待着更好就 false。' +
1038
+ '\n✅ **收到 reply:false 后,你会看到一条 `tool call aborted` 之类的"报错" —— 那正是「静默成功」本身**(本工具就是靠中断输出实现的,设计如此),不是工具坏了、也不是调用失败。看到它请直接收手,什么都不用做、也不用重试。' +
1039
+ '\n⛔ **不要为了"想安静"去动别的开关**(例如把出站模式切成 silent/静默):那会影响**后续所有回合**,而你要的只是"这一轮别说话"。要静默,只走本工具。' +
856
1040
  '\n⚠️ 传 reply:false 后**不要再调用任何工具**(包括记笔记、记喜好、发表情包): 本回合已经关闭, 那些调用会被直接中止 —— 想记录的话, 留到下一次开口的回合里做。',
857
1041
  parameters: {
858
1042
  reply: { type: 'boolean', required: true, description: 'true=正常开口回复; false=静默终止本回合(不再输出任何内容)' },
@@ -2272,6 +2456,21 @@ export async function apply(ctx) {
2272
2456
  diag(`注册失败: ${t.name} → ${msg}`);
2273
2457
  }
2274
2458
  }
2459
+ // ══════════════════════════════════════════════════════════════════════════
2460
+ // expose:'all' 支撑(2026-10-10 主人定)—— 扩展工具的"跨通道"能力
2461
+ // ══════════════════════════════════════════════════════════════════════════
2462
+ // 背景:扩展工具的执行体一直靠 `findSessionRec(当前 agent)` 拿 manager/sender,
2463
+ // 而这条链**只在 QQ 会话里成立** —— 在 web / 终端会话里调用,manager 是空的,
2464
+ // 工具就变成空壳(拿不到 appId,也发不出消息)。
2465
+ // 做法:工具文件里声明 `expose: 'all'` 之后 ——
2466
+ // ① 注册名带上**人设 id**(`<name>@<preset>`,如 `send_texts@whale`)标明归属;
2467
+ // ② 执行时注入 `env.qq` = 该人设实例的"QQ 账号包":
2468
+ // { preset, appId, appSecret, owners, groups[], c2c[], sender,
2469
+ // sendText / sendMarkdown / sendImage, apiCall }
2470
+ // ⇒ 非 QQ 会话也能把消息发进**指定的群 / 私聊**。
2471
+ // 不声明 expose 的工具**行为完全不变**(向后兼容)。
2472
+ // ⚠️ 安全性:这些参数只交给本机 `.qqbot-extensions/tools/*.mjs`(主人自己写的代码),
2473
+ // 与内置工具同权限;别把它当成"对外暴露的口子"。
2275
2474
  // ── P4.2 用户扩展工具: 扫描 {cwd}/.qqbot-extensions/tools/ 并注册 ──
2276
2475
  // 扩展文件 run(args, env) 可拿: { cwd, manager, sender, replyTarget, logger }
2277
2476
  // (调用方 session-manager 已 await mountChannelTools, 此处 await 加载不影响时序)。
@@ -2329,19 +2528,45 @@ export async function apply(ctx) {
2329
2528
  return m;
2330
2529
  })();
2331
2530
  for (const def of defs) {
2531
+ // ★ expose:'all' 的工具**不在这里注册** —— 交给 ensureExtToolsGlobal 挂到插件根
2532
+ // (只有挂到那一层,web / 终端等非 QQ 会话才继承得到;在这儿重复注册的话,
2533
+ // QQ 会话会同时看到「带后缀」和「不带后缀」两套工具)
2534
+ // ★ 2026-10-10 修(主人实测抓到):工具可能被改成 expose:'all'(或改回)⇒
2535
+ // 注册名会在「原名 ↔ 原名__人设ID」之间切换。旧名字那份**必须回收**,
2536
+ // 否则会同时存在两份同样的工具(同一份 schema 进上下文两次 ⇒ 白占 token)。
2537
+ if (def.expose === 'all') {
2538
+ const stale = myDisposers.get(def.name);
2539
+ if (stale) {
2540
+ try {
2541
+ stale();
2542
+ }
2543
+ catch { /* 旧句柄已失效 */ }
2544
+ myDisposers.delete(def.name);
2545
+ extDiag('回收旧注册(该工具已改为 expose:all): ' + def.name);
2546
+ }
2547
+ continue;
2548
+ }
2332
2549
  try {
2550
+ // 注:expose:'all' 的工具已在循环开头 continue(它们由 ensureExtToolsGlobal 全局注册)。
2551
+ // 走到这里的都是"只服务 QQ 会话"的工具 ⇒ 名字保持原名,不带 @人设id
2552
+ const scopedName = def.name;
2553
+ // ⚠️ 兜底闸门(同上):扩展工具作者若写了中文名,这里直接不注册 —— 别把整条链路带崩
2554
+ if (!/^[a-zA-Z0-9_-]+$/.test(scopedName)) {
2555
+ extDiag('跳过非法工具名(中文等): ' + scopedName);
2556
+ continue;
2557
+ }
2333
2558
  // 同一个 ctx 再来一遍(热刷)= 更新已有工具:先摘掉我们自己上次注册的那份
2334
- const prev = myDisposers.get(def.name);
2559
+ const prev = myDisposers.get(scopedName);
2335
2560
  if (prev) {
2336
2561
  try {
2337
2562
  prev();
2338
2563
  }
2339
2564
  catch { /* 旧句柄已失效,忽略 */ }
2340
- myDisposers.delete(def.name);
2565
+ myDisposers.delete(scopedName);
2341
2566
  extDiag(`热替换: 先注销旧 ${def.name}`);
2342
2567
  }
2343
2568
  const tool = defineTool({
2344
- name: def.name,
2569
+ name: scopedName,
2345
2570
  description: def.description + ' (用户扩展工具)',
2346
2571
  parameters: def.inputSchema,
2347
2572
  output: {
@@ -2402,6 +2627,8 @@ export async function apply(ctx) {
2402
2627
  if (!(k in env))
2403
2628
  env[k] = v;
2404
2629
  env.caps = caps;
2630
+ // 注:expose:'all' 的工具带 env.qq 的那份在 ensureExtToolsGlobal 里全局注册
2631
+ // (非 QQ 会话只有继承到那一份才能用),这里不再重复注入。
2405
2632
  }
2406
2633
  catch (err) {
2407
2634
  extDiag(`能力包构造失败(不影响工具本身): ${err instanceof Error ? err.message : String(err)}`);
@@ -2420,8 +2647,8 @@ export async function apply(ctx) {
2420
2647
  const dispose = ctx.tools.register(tool);
2421
2648
  // 收下 disposer —— 下次(热刷/重装配)靠它做同名替换,不然只能重启宿主
2422
2649
  if (typeof dispose === 'function')
2423
- myDisposers.set(def.name, dispose);
2424
- ctx.logger?.info?.(`[channel-tools] 已注册扩展工具: ${def.name}`);
2650
+ myDisposers.set(scopedName, dispose);
2651
+ ctx.logger?.info?.(`[channel-tools] 已注册扩展工具: ${scopedName}`);
2425
2652
  diag(`扩展工具注册成功: ${def.name}`);
2426
2653
  extDiag(`注册成功: ${def.name}`);
2427
2654
  }
@@ -2983,13 +3210,189 @@ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2983
3210
  // 状态没变就一根手指都不动(不产生任何变更通知)。
2984
3211
  // =========================================================================
2985
3212
  /** 三个上下文工具的名字(restrict 用;必须与 ctxToolsRef() 里的 name 完全一致) */
2986
- const CTX_TOOL_NAMES = ['context_memo', 'context_compact', 'context_drop'];
3213
+ /** 需要"按会话剪枝"的工具名(restrict 用)。
3214
+ * ⚠️ 2026-10-10:`qq_group_admin` 也在这里 —— 它跟 context_* 一样是**QQ 通道专属**,
3215
+ * 全局注册后若不在非 QQ 会话里 deny 掉,会白占每个会话的 token(主人要求剪枝覆盖非 QQ 会话)。 */
3216
+ const CTX_TOOL_NAMES = ['context_memo', 'context_compact', 'context_drop', 'qq_group_admin'];
2987
3217
  /**
2988
3218
  * 把"三个上下文工具 + QQ 群管理工具"**全局注册一次、永不注销**。
2989
3219
  *
2990
3220
  * 幂等靠 globalThis 记句柄:跨热刷(模块重新 import)依然有效,避免热刷后重复注册被宿主判重名。
2991
3221
  * ⚠️ 可见性不在这里决定 —— 交给 {@link restrictContextTools} 按会话剪枝。
2992
3222
  */
3223
+ /**
3224
+
3225
+ * 把 `expose: 'all'` 的扩展工具注册到**插件根作用域**(2026-10-10 主人要的"暴露给全部会话")。
3226
+
3227
+ *
3228
+
3229
+ * 为什么必须挂在这一层:扩展工具平时由 session-manager 在**每个 QQ 会话**上用 `agentCtx` 注册,
3230
+
3231
+ * 而 web / 终端等**非 QQ 会话不走那条链** ⇒ 压根看不到它们。想让它们跨通道,
3232
+
3233
+ * 只能挂到插件根 —— 宿主会让所有会话继承插件作用域的工具(`context_*` 三工具当年也是为此才改成全局)。
3234
+
3235
+ *
3236
+
3237
+ * 命名:`<name>@<人设 id>`,并且**每个账号实例各注册一份**(实例各有自己的 preset / appId / 账号数据);
3238
+
3239
+ * 执行时由注册名里的 tag 反查账号包(见 buildWorkspaceQqPack)—— **名字即归属**。
3240
+
3241
+ *
3242
+
3243
+ * 幂等:句柄存 globalThis(跨热刷可见),已注册过的 `name@tag` 不重复注册。
3244
+
3245
+ * fail-soft:拿不到 tools 服务 / 没有实例 / 目录读不到,一律静默跳过,绝不影响主流程。
3246
+
3247
+ */
3248
+ export function ensureExtToolsGlobal(globalCtx) {
3249
+ // 节流:本函数挂在 agent/pre-step(**每个 step** 都触发)—— 15 秒内不重复扫盘;
3250
+ // 幂等保证:即便节流窗口外重复进入,已注册的 name@tag 也会被跳过。
3251
+ extGlobDiag('enter ctxType=' + (globalCtx === null ? 'null' : typeof globalCtx));
3252
+ const nowMs = Date.now();
3253
+ if (nowMs - lastExtGlobAt < 15000)
3254
+ return;
3255
+ lastExtGlobAt = nowMs;
3256
+ extGlobDiag('pass-throttle');
3257
+ void (async () => {
3258
+ try {
3259
+ if (!globalCtx || typeof globalCtx !== 'object')
3260
+ return;
3261
+ // ★ 2026-10-10 关键修复:必须拿**插件根 ctx**。
3262
+ // 证据:主人日志里 context_* + qq_group_admin + 人家的两个工具被**一起移除** ——
3263
+ // 说明它们其实都挂在**会话作用域**上(会话一换就没了),也就是说 this.ctx 不是根。
3264
+ // cordis 的 ctx.root 才是根作用域;拿不到就退回原值(行为不变)。
3265
+ const rootCtx = ((globalCtx?.root) ?? globalCtx);
3266
+ extGlobDiag('root=' + (rootCtx === globalCtx ? 'same-as-passed' : 'DIFFERS'));
3267
+ const gctx = rootCtx;
3268
+ let toolsObj;
3269
+ try {
3270
+ toolsObj = gctx.get?.('tools');
3271
+ }
3272
+ catch { /* ignore */ }
3273
+ if (!toolsObj || typeof toolsObj.register !== 'function') {
3274
+ extGlobDiag('FAIL 拿不到 tools 服务');
3275
+ extDiag('[extglob] 拿不到 tools 服务, 跳过');
3276
+ return;
3277
+ }
3278
+ // 实例清单:每个账号实例注册一份(tag = 人设 id)
3279
+ const insts = [];
3280
+ try {
3281
+ for (const m of managersOf()) {
3282
+ const mm = m;
3283
+ const tag = instanceTagOfManager(mm);
3284
+ if (tag && mm.dataRoot)
3285
+ insts.push({ tag, dataRoot: String(mm.dataRoot), cwd: String(mm.cwd ?? '') });
3286
+ }
3287
+ }
3288
+ catch { /* ignore */ }
3289
+ extGlobDiag('instances=' + insts.length + ' :: ' + insts.map((x) => x.tag).join(','));
3290
+ if (!insts.length) {
3291
+ extGlobDiag('FAIL 暂无实例');
3292
+ extDiag('[extglob] 暂无实例, 跳过');
3293
+ return;
3294
+ }
3295
+ const g = globalThis;
3296
+ if (!g.__qqbotExtToolsGlobal)
3297
+ g.__qqbotExtToolsGlobal = new Map();
3298
+ const reg = g.__qqbotExtToolsGlobal;
3299
+ for (const inst of insts) {
3300
+ let defs = [];
3301
+ try {
3302
+ defs = await loadExtensionTools(inst.dataRoot, (gctx.logger ?? console));
3303
+ }
3304
+ catch {
3305
+ defs = [];
3306
+ }
3307
+ const exposed = defs.filter((d) => d.expose === 'all');
3308
+ extGlobDiag('inst=' + inst.tag + ' defs=' + defs.length + ' exposeAll=' + exposed.length + ' names=' + defs.map((d) => d.name + (d.expose === 'all' ? '*' : '')).join('|'));
3309
+ if (!exposed.length)
3310
+ continue;
3311
+ for (const def of exposed) {
3312
+ // ⚠️ 2026-10-10 事故真凶:分隔符必须在 [a-zA-Z0-9_-] 之内 ——
3313
+ // `@` 不在 LLM API 允许的工具名字符集里(^[a-zA-Z0-9_-]+$),
3314
+ // 一旦注册进去就是"每一轮请求都被拒"(机器人整体瘫痪)。故改用 `__`。
3315
+ const full = def.name + '__' + inst.tag;
3316
+ // ⚠️ 兜底闸门:工具名只允许 [a-zA-Z0-9_-](LLM API 硬约束)——
3317
+ // 非法名一旦注册进工具表,**每一轮请求都会被 API 整轮拒绝**(2026-10-10 事故)。
3318
+ if (!/^[a-zA-Z0-9_-]+$/.test(full)) {
3319
+ extGlobDiag('SKIP illegal global name: ' + full);
3320
+ continue;
3321
+ }
3322
+ if (reg.has(full))
3323
+ continue;
3324
+ try {
3325
+ const tool = defineTool({
3326
+ name: full,
3327
+ description: def.description + ' (用户扩展工具 · ' + inst.tag + ')',
3328
+ parameters: def.inputSchema,
3329
+ output: {
3330
+ schema: {
3331
+ type: 'object', additionalProperties: false,
3332
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
3333
+ },
3334
+ render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : ('失败: ' + v.msg) }],
3335
+ },
3336
+ async execute(args, exec) {
3337
+ // 账号包:按注册名里的 tag 反查(这就是"名字即归属")
3338
+ let pack;
3339
+ try {
3340
+ pack = buildWorkspaceQqPack(inst.tag, inst.dataRoot);
3341
+ }
3342
+ catch {
3343
+ pack = undefined;
3344
+ }
3345
+ const env = {
3346
+ cwd: inst.cwd || inst.dataRoot,
3347
+ exec,
3348
+ ctx: globalCtx,
3349
+ logger: gctx.logger,
3350
+ qq: pack,
3351
+ };
3352
+ // 若这一次调用恰好来自 QQ 会话,则会话级上下文优先(sender/replyTarget/manager 用当前会话的)
3353
+ try {
3354
+ const sess = findSessionRec(channelOf(exec), exec);
3355
+ if (sess) {
3356
+ env.manager = sess.ch.manager;
3357
+ env.sender = sess.ch.sender;
3358
+ env.replyTarget = sess.rec.replyTarget;
3359
+ }
3360
+ else if (pack) {
3361
+ env.sender = pack.sender;
3362
+ }
3363
+ }
3364
+ catch { /* ignore */ }
3365
+ try {
3366
+ const r = await def.run(args, env);
3367
+ if (r && typeof r === 'object')
3368
+ return { ok: r.ok === true, msg: String(r.msg ?? 'done') };
3369
+ return { ok: true, msg: r === undefined || r === null ? 'done' : String(r) };
3370
+ }
3371
+ catch (err) {
3372
+ return { ok: false, msg: err instanceof Error ? err.message : String(err) };
3373
+ }
3374
+ },
3375
+ });
3376
+ const d = toolsObj.register.call(toolsObj, tool);
3377
+ if (typeof d === 'function')
3378
+ reg.set(full, d);
3379
+ extGlobDiag('OK registered ' + full);
3380
+ extDiag('[extglob] 全局注册 ' + full);
3381
+ gctx.logger?.info?.('[channel-tools] 扩展工具已暴露给全部会话: ' + full);
3382
+ }
3383
+ catch (e) {
3384
+ extGlobDiag('FAIL register ' + full + ' :: ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 200));
3385
+ extDiag('[extglob] 注册失败 ' + full + ': ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 160));
3386
+ }
3387
+ }
3388
+ }
3389
+ }
3390
+ catch (e) {
3391
+ extGlobDiag('EXCEPTION ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 200));
3392
+ extDiag('[extglob] 异常: ' + ((e instanceof Error ? e.message : String(e)) || '').slice(0, 160));
3393
+ }
3394
+ })();
3395
+ }
2993
3396
  export function ensureContextToolsGlobal(globalCtx) {
2994
3397
  try {
2995
3398
  if (!globalCtx || typeof globalCtx !== 'object')