@zaofan/dsh-qqbot 1.6.0 → 1.6.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AA+ElE,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,cAAc,CAAC;IACxB,MAAM,EAAE,WAAW,CAAC;CACrB;AAED,eAAO,MAAM,IAAI,wBAAwB,CAAC;AAG1C,eAAO,MAAM,MAAM,UAA4B,CAAC;AAWhD,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAiC;AA+B5F,wCAAwC;AACxC,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAErH;AACD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAE9E;AACD,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAI/D;AAkFD,mDAAmD;AACnD,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA+4DvD;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,GAAG,KAAK,EACtB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAwCvC;AAiID,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAyI;AACzL,wBAAgB,eAAe,IAAI,MAAM,CAA2F;AA0BpI;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CA+BtJ"}
1
+ {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAsFlE,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,cAAc,CAAC;IACxB,MAAM,EAAE,WAAW,CAAC;CACrB;AAED,eAAO,MAAM,IAAI,wBAAwB,CAAC;AAI1C,eAAO,MAAM,MAAM,UAAY,CAAC;AAWhC,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAiC;AAkD5F,wCAAwC;AACxC,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAErH;AACD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAE9E;AACD,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAI/D;AAkFD,mDAAmD;AACnD,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA+4DvD;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,GAAG,KAAK,EACtB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAwCvC;AA4RD,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAyI;AACzL,wBAAgB,eAAe,IAAI,MAAM,CAA2F;AA8CpI;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CA+CtJ"}
@@ -24,6 +24,8 @@ import { managersOf, findManagerByPeer, findManagerBySessionId } from './feature
24
24
  import { handleInbound } from './transport/inbound.js';
25
25
  import { getQuote } from './features/quote-cache.js';
26
26
  import { appendMemoLine, deleteMemo, listMemos, readMemo } from './features/people-memo.js';
27
+ import { loadQunCookie, saveQunCookie, qunLoginStart, qunLoginPoll, qunLoginPending, qunGroups, qunResolveGc, qunMembers, qunKick, } from './features/qun-admin.js';
28
+ import { qunBrowserLoginStart, qunBrowserLoginWait, qunLoginSessionAlive, } from './features/qun-login.js';
27
29
  import { appendMemo as appendContextMemo, clearMemo as clearContextMemo, deleteMemo as deleteContextMemo, editMemo as editContextMemo, listMemo as listContextMemo, } from './features/context-memo.js';
28
30
  /** 诊断日志路径: 默认关闭; 需要排查时设环境变量 QQBOT_DIAG_FILE 指向日志文件 */
29
31
  const DIAG_FILE = process.env.QQBOT_DIAG_FILE || '';
@@ -94,9 +96,10 @@ function extDiag(line) {
94
96
  catch { /* ignore */ }
95
97
  }
96
98
  export const name = 'qqbot-channel-tools';
97
- // ⚠️ 2026-10-02:用 ctx.systemPrompt.context() 注册运行时贡献**必须**在此声明依赖,
98
- // 否则 ctx.systemPrompt 拿不到 → 注册走"宿主未提供"兜底 → 静默不注入(与当年小传那个坑同源)。
99
- export const inject = ['tools', 'systemPrompt'];
99
+ // ⚠️ 2026-10-02 事故:曾在此加 "systemPrompt" —— 但**宿主未提供该服务**,cordis 会一直等它,
100
+ // 插件报 "never started" ⇒ 所有钩子永不注册 ⇒ 群守则/小传/智能提示/工具刷新全部失效。
101
+ // 那条 systemPrompt 通道已废弃(提示现并入群守则 body 走 pre-step),故只声明 tools。
102
+ export const inject = ['tools'];
100
103
  /**
101
104
  * 运行时按当前执行 agent 解析 qqChannel service。
102
105
  * 解析顺序: ①exec.agent.ctx.get('qqChannel')(agent ctx, setup 时 provide);
@@ -111,8 +114,9 @@ function getContextlessSmartGlobal() { return ctxSmartGlobal; }
111
114
  /** 无上下文·智能判断:取当前会话的 dataRoot + sessionKey */
112
115
  async function resolveCtxMemoEnv(exec) {
113
116
  // ★ 优先用注册时记住的会话坐标(工具里的 exec.agent 与 pre-step 的 agent 是两个不同对象,反查常失败)
117
+ // ⚠️ 必须要求 sessionKey 非空:备忘/压缩都按会话分文件存,空 key 会让所有会话串到同一份。
114
118
  const cached = ctxEnvOf(exec);
115
- if (cached)
119
+ if (cached && cached.sessionKey)
116
120
  return cached;
117
121
  try {
118
122
  const got = findSessionRec(channelOf(exec), exec);
@@ -126,6 +130,24 @@ async function resolveCtxMemoEnv(exec) {
126
130
  return undefined;
127
131
  }
128
132
  }
133
+ /**
134
+ * 只要 dataRoot 的场景(QQ 群管理工具)。
135
+ * pre-step 里 sessionKey 经常是**空**的(实测 [ctxtool-env] sk= 为空),
136
+ * 所以这里不能像备忘那样要求 sessionKey —— 群管理只跟数据目录有关,与会话无关。
137
+ */
138
+ async function resolveCtxDataRoot(exec) {
139
+ const cached = ctxEnvOf(exec);
140
+ if (cached && cached.dataRoot)
141
+ return cached.dataRoot;
142
+ try {
143
+ const got = findSessionRec(channelOf(exec), exec);
144
+ const dataRoot = got?.ch?.manager?.config?.dataRoot;
145
+ if (dataRoot)
146
+ return String(dataRoot);
147
+ }
148
+ catch { /* ignore */ }
149
+ return undefined;
150
+ }
129
151
  /** 登记文件路径(工具只登记,真正压缩在下一回合 pre-step) */
130
152
  function ctxPendingPath(dataRoot, sessionKey) {
131
153
  // ⚠️ 同上:不能保留冒号(Windows 文件名禁用 `:`,会导致 pending 写不进/读不到)
@@ -2543,6 +2565,162 @@ var contextDropTool = defineTool({
2543
2565
  + (a.note ? '(要点已存进你的备忘)' : '(本次没写要点;丢弃后这些历史就找不回来了,建议带 note)') };
2544
2566
  },
2545
2567
  });
2568
+ /**
2569
+ * QQ 群管理工具 —— 用主人自己的 QQ 号管理「我创建 / 我管理」的群。
2570
+ * 纯 HTTP(qun.qq.com 的 qun_mgr 接口),**不需要浏览器**,新用户装完插件自己扫码就能用。
2571
+ * 不受「无上下文」开关影响,永远挂在会话上。
2572
+ */
2573
+ var qunAdminTool = defineTool({
2574
+ name: 'qq_group_admin',
2575
+ description: '用主人自己的 QQ 号管理群(我创建/我管理的群):登录、看群列表、查群成员、踢人。'
2576
+ + '登录用 action=login —— 会自动开一个隐藏的浏览器去「点头像授权」,有 QQ 登录态时**免扫码直接登**;'
2577
+ + '若这台机器上还没有 QQ 登录态,它会出一张二维码,你立刻用 send_media 发给主人扫,再调 action=status 等结果(一次最多等 20 秒,没等到再调)。'
2578
+ + '⚠️ 踢人前必须先跟主人确认「哪个群、哪个人」,确认无误后才带 confirmed=true 调用;绝不自己决定踢谁。',
2579
+ parameters: {
2580
+ action: {
2581
+ type: 'string',
2582
+ required: true,
2583
+ enum: ['status', 'login', 'groups', 'members', 'kick'],
2584
+ description: 'status=登录状态/继续等扫码 / login=出二维码 / groups=群列表 / members=查成员 / kick=踢人',
2585
+ },
2586
+ group: { type: 'string', description: '群号或群名(members/kick 必填;群名支持模糊匹配)' },
2587
+ keyword: { type: 'string', description: 'members 用:uin(精确查)或昵称片段' },
2588
+ uins: { type: 'string', description: 'kick 用:要踢的 uin,多个用逗号分隔' },
2589
+ confirmed: { type: 'boolean', description: 'kick 用:主人已明确同意踢这些人时才传 true' },
2590
+ },
2591
+ output: {
2592
+ schema: {
2593
+ type: 'object',
2594
+ additionalProperties: true,
2595
+ properties: {
2596
+ ok: { type: 'boolean', required: true },
2597
+ msg: { type: 'string', required: true },
2598
+ },
2599
+ },
2600
+ render: (_a, v) => [{ type: 'text', text: String(v?.msg ?? '') }],
2601
+ },
2602
+ async execute(args, exec) {
2603
+ // 群管理只认数据目录(不要 sessionKey —— pre-step 里它经常是空的)
2604
+ const dataRoot = await resolveCtxDataRoot(exec);
2605
+ if (!dataRoot)
2606
+ return { ok: false, msg: '没找到会话的数据目录,无法读写 QQ 登录凭据' };
2607
+ const a = (args ?? {});
2608
+ const action = String(a.action || 'status');
2609
+ try {
2610
+ // ── 登录状态 / 继续等扫码 ──
2611
+ if (action === 'status') {
2612
+ // ① 浏览器会话还在等(免扫码那条路)→ 先问它
2613
+ if (qunLoginSessionAlive(dataRoot)) {
2614
+ const w = await qunBrowserLoginWait(dataRoot, 20000);
2615
+ if (w.cookie) {
2616
+ saveQunCookie(dataRoot, { ...w.cookie, at: Date.now() });
2617
+ return { ok: true, msg: `登录成功(浏览器头像授权),uin=${w.cookie.uin}。可以直接 groups / members / kick 了。` };
2618
+ }
2619
+ return { ok: w.ok, msg: w.msg };
2620
+ }
2621
+ const c = loadQunCookie(dataRoot);
2622
+ if (c && !qunLoginPending(dataRoot)) {
2623
+ return { ok: true, msg: `已登录:uin=${c.uin}(凭据可用)。可以直接 groups / members / kick。` };
2624
+ }
2625
+ if (qunLoginPending(dataRoot)) {
2626
+ const p = await qunLoginPoll(dataRoot, 20000);
2627
+ if (p.done)
2628
+ return { ok: true, msg: p.msg };
2629
+ return { ok: p.ok, msg: p.msg + '(还没确认就再调一次 status;二维码 3 分钟有效)' };
2630
+ }
2631
+ return { ok: false, msg: '还没登录。请调 action=login。' };
2632
+ }
2633
+ // ── 登录:① 先静默无头试(有登录态/有头像就零打扰完成);② 要扫码就把二维码送上 dsh 网页 ──
2634
+ if (action === 'login') {
2635
+ const b = await qunBrowserLoginStart(dataRoot, { headless: true });
2636
+ if (b.cookie) {
2637
+ saveQunCookie(dataRoot, { ...b.cookie, at: Date.now() });
2638
+ return {
2639
+ ok: true,
2640
+ msg: `免扫码登录成功(静默点头像授权,全程没弹窗口),uin=${b.cookie.uin}。可以直接用 groups / members / kick 了。`,
2641
+ };
2642
+ }
2643
+ // ⚠️ 需要人扫码时**不要试图弹浏览器窗口**:实测宿主 spawn 的浏览器窗口根本到不了用户桌面
2644
+ // (headless=False 也一样,进程查不到任何可见窗口)。也别发图 —— 用户在手机上收到图
2645
+ // 扫不了自己的屏幕。最稳的做法:**直接给出二维码的本地文件路径**,用户在自己电脑上打开它扫。
2646
+ if (b.qrPath) {
2647
+ return {
2648
+ ok: true,
2649
+ msg: '需要扫一次码。二维码已经生成到本地文件:\n'
2650
+ + ' ' + b.qrPath + '\n'
2651
+ + '请在**电脑上打开这张图片**(双击它、或用看图软件),然后用手机 QQ 的「扫一扫」,'
2652
+ + '**摄像头对着电脑屏幕**扫 —— 腾讯只认摄像头,用相册或长按识别会被拒绝。'
2653
+ + '扫完调 action=status 等结果。',
2654
+ };
2655
+ }
2656
+ // ③ 浏览器这条路不通(没装/起不来)→ 退回纯网络二维码(也是同一个本地文件路径)
2657
+ const s = await qunLoginStart(dataRoot);
2658
+ if (!s.ok || !s.qrPath)
2659
+ return { ok: false, msg: '登录失败:' + b.msg + ' / ' + s.msg };
2660
+ return {
2661
+ ok: true,
2662
+ msg: '(浏览器方式不可用,已改用纯网络二维码)二维码已生成到本地文件:\n'
2663
+ + ' ' + s.qrPath + '\n'
2664
+ + '在电脑上打开这张图,用手机 QQ 摄像头对着屏幕扫,然后调 action=status 等结果。',
2665
+ };
2666
+ }
2667
+ // 以下都要已登录
2668
+ if (!loadQunCookie(dataRoot)) {
2669
+ return { ok: false, msg: '还没登录 QQ。先调 action=login 出二维码(再用 send_media 发给主人扫)。' };
2670
+ }
2671
+ // ── 群列表 ──
2672
+ if (action === 'groups') {
2673
+ const gs = await qunGroups(dataRoot);
2674
+ if (!gs.length)
2675
+ return { ok: true, msg: '我名下没有「我创建/我管理」的群。' };
2676
+ return {
2677
+ ok: true,
2678
+ msg: `共 ${gs.length} 个群(群号\t群名\t身份):\n` + gs.map((g) => `${g.gc}\t${g.gn}\t[${g.role}]`).join('\n'),
2679
+ };
2680
+ }
2681
+ // ── 查成员 ──
2682
+ if (action === 'members') {
2683
+ const res = await qunResolveGc(dataRoot, String(a.group || ''));
2684
+ if (!res.ok)
2685
+ return { ok: false, msg: res.msg };
2686
+ const kw = String(a.keyword || '').trim();
2687
+ const m = await qunMembers(dataRoot, res.gc, kw || undefined);
2688
+ const head = `群 ${res.gc}${res.gn ? '(' + res.gn + ')' : ''} 共 ${m.count ?? '?'} 人`
2689
+ + (kw ? `,命中 ${m.members.length} 人` : `,本次取回 ${m.members.length} 人`) + (m.note || '') + ':';
2690
+ const show = m.members.slice(0, 80);
2691
+ const body = show.map((x) => `${x.uin}\t${x.nick}`).join('\n');
2692
+ const tail = m.members.length > show.length ? `\n…(还有 ${m.members.length - show.length} 人,建议加 keyword 精确查)` : '';
2693
+ return { ok: true, msg: m.members.length ? head + '\n' + body + tail : head + '\n(无匹配)' };
2694
+ }
2695
+ // ── 踢人 ──
2696
+ if (action === 'kick') {
2697
+ const list = String(a.uins || '').split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean);
2698
+ if (!list.length)
2699
+ return { ok: false, msg: '请给要踢的 uin(uins 参数,多个用逗号分隔)' };
2700
+ if (a.confirmed !== true) {
2701
+ return { ok: false, msg: `还没确认:准备踢 ${list.join('、')}。请先跟主人核对「群 + 人」,主人明确同意后再带 confirmed=true 调用。` };
2702
+ }
2703
+ const res = await qunResolveGc(dataRoot, String(a.group || ''));
2704
+ if (!res.ok)
2705
+ return { ok: false, msg: res.msg };
2706
+ const rs = await qunKick(dataRoot, res.gc, list);
2707
+ const okCnt = rs.filter((x) => x.ec === 0).length;
2708
+ return {
2709
+ ok: okCnt > 0,
2710
+ msg: `群 ${res.gc}${res.gn ? '(' + res.gn + ')' : ''} 踢人结果(成功 ${okCnt}/${rs.length}):\n`
2711
+ + rs.map((x) => `${x.uin}\t${x.ec === 0 ? '✅' : '❌'} ${x.msg}`).join('\n'),
2712
+ };
2713
+ }
2714
+ return { ok: false, msg: '未知 action:' + action };
2715
+ }
2716
+ catch (e) {
2717
+ const msg = String(e?.message ?? e);
2718
+ if (msg.includes('NOT_LOGGED_IN'))
2719
+ return { ok: false, msg: '登录已失效,请重新调 action=login 出二维码。' };
2720
+ return { ok: false, msg: '出错:' + msg.slice(0, 200) };
2721
+ }
2722
+ },
2723
+ });
2546
2724
  // 模块级直接拿(不依赖 apply —— apply 只在会话首建时跑,agent 重建后不会重跑)
2547
2725
  // 注意:三个工具定义在同文件下方(var 声明,有提升),故这里用取值函数避免 TDZ。
2548
2726
  /**
@@ -2566,6 +2744,17 @@ function ctxToolsRef() {
2566
2744
  }
2567
2745
  return CTX_TOOLS_REF;
2568
2746
  }
2747
+ let QUN_TOOLS_REF = [];
2748
+ /** 惰性取 QQ 群管理工具(不分开关,永远挂) */
2749
+ function qunAdminRef() {
2750
+ if (QUN_TOOLS_REF.length === 0) {
2751
+ try {
2752
+ QUN_TOOLS_REF = [qunAdminTool];
2753
+ }
2754
+ catch { /* ignore */ }
2755
+ }
2756
+ return QUN_TOOLS_REF;
2757
+ }
2569
2758
  const ctxEnvMap = new WeakMap();
2570
2759
  /** 工具执行时取会话坐标:优先用注册时记下的(最准),取不到再回退反查 */
2571
2760
  function ctxEnvOf(exec) {
@@ -2580,6 +2769,18 @@ function ctxEnvOf(exec) {
2580
2769
  catch { /* fallthrough */ }
2581
2770
  return undefined;
2582
2771
  }
2772
+ /** 工具挂载诊断:直接追加到 ~/.dsh/contextless-trace.log(与 session-manager 的 trace 同一文件)
2773
+ * 为什么要它:插件工具层(注册/挂载)以前完全没法验证,只能靠猜。有了这行,重启后一眼就能看出
2774
+ * 「群管理工具有没有真的挂上去、dataRoot 传的是什么」。 */
2775
+ function traceToolReg(line) {
2776
+ try {
2777
+ const home = process.env['HOME'] || process.env['USERPROFILE'] || '';
2778
+ if (!home)
2779
+ return;
2780
+ appendFileSync(join(home, '.dsh', 'contextless-trace.log'), `[${new Date().toISOString()}] ${line}\n`);
2781
+ }
2782
+ catch { /* ignore */ }
2783
+ }
2583
2784
  /**
2584
2785
  * 按会话开关**动态**注册/注销三个上下文工具(context_memo / context_compact / context_drop)。
2585
2786
  * 由 session-manager 在每个回合的 pre-step 里调用(拿 rec.agentCtx)。
@@ -2593,8 +2794,10 @@ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2593
2794
  const key = agentCtx;
2594
2795
  try {
2595
2796
  // ① 先记会话坐标(每次 pre-step 都刷新;这是工具执行时唯一的可靠来源)
2596
- if (env && env.dataRoot && env.sessionKey) {
2597
- ctxEnvMap.set(key, { dataRoot: String(env.dataRoot), sessionKey: String(env.sessionKey) });
2797
+ // ⚠️ 只要 dataRoot 就够了:pre-step 里 sessionKey 经常为空(实测 sk= 空),
2798
+ // 但群管理工具只要数据目录;备忘类工具由 resolveCtxMemoEnv 自己要求 sessionKey 非空。
2799
+ if (env && env.dataRoot) {
2800
+ ctxEnvMap.set(key, { dataRoot: String(env.dataRoot), sessionKey: String(env.sessionKey ?? '') });
2598
2801
  }
2599
2802
  const cur = ctxToolDisposers.get(key);
2600
2803
  // ⚠️ 2026-10-02:不能靠"缓存里已注册"就跳过 —— agent/ctx 一变,实际工具表就作废了,
@@ -2609,11 +2812,12 @@ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2609
2812
  }
2610
2813
  ctxToolDisposers.delete(key);
2611
2814
  }
2815
+ const reg = ctxObj.tools?.register;
2816
+ if (typeof reg !== 'function')
2817
+ return;
2818
+ const ds = [];
2819
+ // ① 三个上下文工具:只在「无上下文·智能判断」开启时挂
2612
2820
  if (shouldHave) {
2613
- const reg = ctxObj.tools?.register;
2614
- if (typeof reg !== 'function')
2615
- return;
2616
- const ds = [];
2617
2821
  for (const t of ctxToolsRef()) {
2618
2822
  try {
2619
2823
  const d = reg.call(ctxObj.tools, t);
@@ -2622,10 +2826,25 @@ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2622
2826
  }
2623
2827
  catch { /* 单个工具注册失败不影响其他 */ }
2624
2828
  }
2625
- if (ds.length)
2626
- ctxToolDisposers.set(key, ds);
2627
2829
  }
2830
+ // ② QQ 群管理工具:QQ 通道的会话都挂。
2831
+ // 注:syncContextTools 本来就**只在 QQ 会话**里被调用(web/终端会话不经过这里),
2832
+ // 所以不存在"污染其它会话"的问题。2026-10-02 曾误加"只挂私聊"限制,主人指出多余,已撤销。
2833
+ for (const t of qunAdminRef()) {
2834
+ try {
2835
+ const d = reg.call(ctxObj.tools, t);
2836
+ if (typeof d === 'function')
2837
+ ds.push(d);
2838
+ }
2839
+ catch { /* ignore */ }
2840
+ }
2841
+ if (ds.length)
2842
+ ctxToolDisposers.set(key, ds);
2843
+ traceToolReg(`[ctxtool-reg] shouldHave=${shouldHave} 挂载=${ds.length} 件 dataRoot=${String(env?.dataRoot ?? '')} sk=${String(env?.sessionKey ?? '')}`);
2844
+ }
2845
+ catch (e) {
2846
+ // fail-soft:工具挂载失败绝不影响会话;但必须留下证据
2847
+ traceToolReg('[ctxtool-reg] 异常: ' + String(e?.message ?? e).slice(0, 160));
2628
2848
  }
2629
- catch { /* fail-soft:工具挂载失败绝不影响会话 */ }
2630
2849
  }
2631
2850
  //# sourceMappingURL=channel-tools.js.map