@zaofan/dsh-qqbot 1.6.1 → 1.6.3

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
@@ -30,11 +30,14 @@
30
30
  - 群消息聚合时按「价值 × 好感」**加权平均**综合判断,不会被一句话钓走
31
31
 
32
32
  **💬 悬浮球 dock:她的随身控制台**
33
- 设置面板右下角的小球,点开就是一整个操作台:**💬 聊天**(像 QQ 一样回放群/私聊记录,气泡+头像,图能放大、本地视频能播、SILK 语音转 mp3 直接听、文件出下载卡,还能在聊天框里直接发文字/插图/发文件——长文本自动拆条连发不被吞);**📥 入群审批**、**🔇 禁言**(机器人为群管理员时,有人申请进群她会提醒你,回句"通过/拒绝"就批);**⚙️ 出站**(适配主动:刚收到真人消息时前几条带引用回你、连发自动转独立消息,定时/后台推送不打扰)。
33
+ 设置面板右下角的小球,点开就是一整个操作台:**💬 聊天**(像 QQ 一样回放群/私聊记录,气泡+头像,图能放大、本地视频能播、SILK 语音转 mp3 直接听、文件出下载卡,还能在聊天框里直接发文字/插图/发文件——长文本自动拆条连发不被吞);**📥 入群审批**、**🔨 锤子**(下面分 **🔇 禁言** 和 **👢 踢人** 两页;踢人用你自己的 QQ 号管群,扫码一次之后长期免扫,登录态与 AI 共享);**⚙️ 出站**(适配主动:刚收到真人消息时前几条带引用回你、连发自动转独立消息,定时/后台推送不打扰)。
34
34
 
35
35
  **🛡️ 群主/群管好帮手**
36
36
  入群审批 + 禁言 + 查成员,全走官方接口,出错给"人话"(不是管理员/不能禁群主……都告诉你为什么)。
37
37
 
38
+ **👢 踢人(v1.6.3)**
39
+ QQ 官方**没有开放踢人接口**,所以这一块走"你自己的 QQ 号 + 群官网":面板 **🔨 锤子 → 👢 踢人** 里刷新二维码、手机扫一次就登(之后长期免扫);登录态和 AI 共用 —— 你也可以直接在 QQ 里跟她说"把群里那个 X 踢了"。
40
+
38
41
  **🗂️ 归档了也会自己回家**
39
42
  不小心把她的会话点了「归档」藏进侧边栏深处?不用满世界找——那个会话**下次在 QQ 里被消息触发时**,会自己从归档里摘出来回到侧边栏(你主动归档的其它会话不受影响)。
40
43
 
@@ -270,6 +273,60 @@ dsh: disabling profile plugin row "mcp-chrome": Plugin ... is incompatible with
270
273
 
271
274
  想强行运行,可用官方提供的**确切版本例外**(`dsh plugin allow-version`,或插件管理页里授予),但官方警告"可能崩溃或数据丢失",自行权衡。
272
275
 
276
+ ## 🔨 锤子面板:禁言 / 踢人(v1.6.3)
277
+
278
+ dock 面板里的 **🔨 锤子**(原来叫「🔇 禁言」),下面分两页:
279
+
280
+ | 子页 | 走哪条路 | 说明 |
281
+ |---|---|---|
282
+ | **🔇 禁言** | 官方 Bot 接口 | 机器人为群管理员即可用,无需任何登录 |
283
+ | **👢 踢人** | **你自己的 QQ 号**(群官网接口) | 官方没开放踢人接口,所以这里要登一次 |
284
+
285
+ ### 怎么用(踢人)
286
+
287
+ 1. 面板 → **🔨 锤子** → **👢 踢人** → 点 **📱 刷新二维码**
288
+ 2. 页面里**直接显示二维码** —— 拿手机 QQ「扫一扫」,**摄像头对着电脑屏幕**扫
289
+ > 腾讯只认摄像头:用相册选图 / 长按识别会提示「本次请求不支持图片识别或长按扫描二维码授权」
290
+ 3. 扫完自动切到已登录界面:**选群 + 搜成员(uin 精确 / 昵称模糊)+ 点一下 👢 踢**
291
+
292
+ ![🔨 锤子 · 踢人面板:搜「鱼」的结果,每人一个 👢 踢按钮](docs/screenshots/hammer-kick-panel.png)
293
+
294
+ ### 免扫码是怎么做到的
295
+
296
+ 登录成功后凭据存在 `{dataRoot}/qun-cookie.json`。之后每次需要登录时:
297
+
298
+ ```
299
+ 静默起一个「无头」浏览器 → 把已存的凭据注入进去(CDP Network.setCookie)
300
+ → 页面直接就是已登录状态 → 读出凭据 → 关掉浏览器
301
+ ```
302
+
303
+ **全程不弹窗口、不用扫码**。只有第一次(或凭据彻底过期)才要扫一次;`skey` 约 1 天后过期,重登走的也是上面这套静默流程。
304
+
305
+ > 💡 实测彩蛋:**本机开着 QQ 客户端**时,登录页会直接显示你的头像,点一下就登 —— 连第一次都不用扫。
306
+
307
+ ### 登录态与 AI 共享
308
+
309
+ 面板和 AI 读写的是**同一个** `qun-cookie.json`,所以两边天然同步:
310
+
311
+ - 你在面板扫码登录 → 直接跟 AI 说「把群里那个 X 踢了」就能用
312
+ - AI 先登过 → 你打开面板就是已登录
313
+
314
+ AI 侧的工具叫 **`qq_group_admin`**:
315
+
316
+ | action | 作用 |
317
+ |---|---|
318
+ | `status` | 看登录状态 / 继续等扫码 |
319
+ | `login` | 登录(静默优先,需要才给二维码路径) |
320
+ | `groups` | 列出你创建/管理的群 |
321
+ | `members` | 查成员(uin 精确 / 昵称模糊 / 全量) |
322
+ | `kick` | 踢人 —— **必须带 `confirmed=true`**,否则只回一句「请先跟主人核对群和人」 |
323
+
324
+ ### 隐私与进程
325
+
326
+ - 浏览器用**独立 profile**(在插件数据目录里),**看不到你日常浏览器的任何东西**
327
+ - **不常驻**:拿到凭据立刻关;等扫码最多留 5 分钟;宿主退出时会一起带走(不留孤儿进程)
328
+ - 凭据只存在你自己的机器上,仓库里不含任何凭据
329
+
273
330
  ## 🚫 无上下文模式(按会话省 token)
274
331
 
275
332
  让某个 QQ 会话**每轮只记得最近几条对话**,更早的历史自动折叠 —— 群聊省 token 利器。
@@ -1932,7 +1932,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
1932
1932
  setInterval(refreshBadge, 20000)
1933
1933
 
1934
1934
  // ── 面板状态(每个实例独立保存, 切回不丢) ──
1935
- var state = { ns: '', accts: [], gid: '', groups: [], tab: 'chat', sendScope: 'group', sendTo: '', sendName: '', sendText: '', insertCtx: true, targetQ: '', c2cs: [], joins: null, mutes: null, members: null, muteSecs: '60', bindGid: '', bindName: '', msg: '', busy: '', wantPeer: null, lookedUp: false, detected: null, detectedHit: null, chatItems: [], chatMore: false, chatBusy: '', chatErr: '', chatOldest: 0, chatText: '', chatIns: true, outMode: '', outRev: undefined, bpEvents: [], bpSel: null, bpDraft: null, rosterSel: {}, rosterScope: 'all', rosterQ: '', hubSid: '', hubRev: undefined, hubBusy: '', hubMsg: '', gaEnabled: false, gaPoll: false, gaPollWake: true, gaHubNotify: true, gaNotifyGroup: true, gaInterval: 5, gaMinCount: 1, gaMsg: '', gaBusy: '', bcDraft: null, bcTasks: null, cardMd: '', cardBtns: '', cardGid: '', cardBusy: '', cardQ: '', tgGroups: [], tgCur: '' }
1935
+ var state = { ns: '', accts: [], gid: '', groups: [], tab: 'chat', sendScope: 'group', sendTo: '', sendName: '', sendText: '', insertCtx: true, targetQ: '', c2cs: [], joins: null, mutes: null, members: null, muteSecs: '60', bindGid: '', bindName: '', msg: '', busy: '', wantPeer: null, lookedUp: false, detected: null, detectedHit: null, chatItems: [], chatMore: false, chatBusy: '', chatErr: '', chatOldest: 0, chatText: '', chatIns: true, outMode: '', outRev: undefined, bpEvents: [], bpSel: null, bpDraft: null, rosterSel: {}, rosterScope: 'all', rosterQ: '', hubSid: '', hubRev: undefined, hubBusy: '', hubMsg: '', gaEnabled: false, gaPoll: false, gaPollWake: true, gaHubNotify: true, gaNotifyGroup: true, gaInterval: 5, gaMinCount: 1, gaMsg: '', gaBusy: '', bcDraft: null, bcTasks: null, cardMd: '', cardBtns: '', cardGid: '', cardBusy: '', cardQ: '', tgGroups: [], tgCur: '', hammerSub: 'mute', qunLogged: null, qunUin: '', qunGroups: [], qunGc: '', qunKw: '', qunMembers: [], qunCount: 0, qunQr: false, qunMsg: '', qunBusy: false, qunLoading: false }
1936
1936
  // 🗂 自定义目标分组(仿 QQ 分组): 2026-09-12 起**host 持久化**({dataRoot}/.qqbot/target-groups.json),
1937
1937
  // localStorage 只当秒开缓存 —— 这样 **AI 与主人共用同一份分组**(agent 侧 broadcast_send 可直接写分组名群发),
1938
1938
  // 顺带修掉"换个浏览器分组就没了"的老毛病。
@@ -3488,7 +3488,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3488
3488
  + '<button data-t="roster" class="' + (state.tab === 'roster' ? 'on' : '') + '">📇 群组管理</button>'
3489
3489
  + '<button data-t="chat" class="' + (state.tab === 'chat' ? 'on' : '') + '">💬 聊天</button>'
3490
3490
  + '<button data-t="join" class="' + (state.tab === 'join' ? 'on' : '') + '">📥 入群审批<span class="dk-join-badge" style="display:none;background:#ff4d4f;color:#fff;border-radius:8px;font-size:11px;padding:0 5px;margin-left:4px">0</span></button>'
3491
- + '<button data-t="mute" class="' + (state.tab === 'mute' ? 'on' : '') + '">🔇 禁言</button>'
3491
+ + '<button data-t="mute" class="' + (state.tab === 'mute' ? 'on' : '') + '">🔨 锤子</button>'
3492
3492
  + '<button data-t="out" class="' + (state.tab === 'out' ? 'on' : '') + '">⚙️ 出站</button>'
3493
3493
  + '<button data-t="send" class="' + (['send','broadcast','bp','card'].indexOf(state.tab) >= 0 ? 'on' : '') + '">📤 群发</button>'
3494
3494
  + '<button data-t="session" class="' + (state.tab === 'session' ? 'on' : '') + '">⚙ 单会话设置</button>'
@@ -3574,13 +3574,23 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3574
3574
  + '<span class="dk-msg">机器人需为群管理员; 通过/拒绝直接生效</span></div>'
3575
3575
  body += '<div class="dk-list" id="dk-join-list"></div>'
3576
3576
  } else if (state.tab === 'mute') {
3577
- body += '<div class="dk-row">目标群: ' + groupSelHtml
3578
- + '<button class="dk-btn" id="dk-refresh-mute">🔄 刷新</button></div>'
3579
- body += '<div class="dk-msg">全员禁言: 官方接口未开放 · 只能操作普通成员(群主/管理员不可禁)</div>'
3580
- body += '<div style="font-weight:700;font-size:13px;margin:8px 0 4px">① 禁言成员(机器人见过的)</div>'
3581
- body += '<div class="dk-list" id="dk-member-list"></div>'
3582
- body += '<div style="font-weight:700;font-size:13px;margin:8px 0 4px">② 正在禁言中</div>'
3583
- body += '<div class="dk-list" id="dk-mute-list"></div>'
3577
+ // 🔨 锤子(2026-10-02 主人定):原「禁言」升级成一个锤子,下面分两个子标签
3578
+ var hsub = state.hammerSub || 'mute'
3579
+ body += '<div class="dk-row" style="gap:6px;margin:2px 0">'
3580
+ + '<button class="dk-btn' + (hsub === 'mute' ? ' ok' : '') + '" data-hsub="mute">🔇 禁言</button>'
3581
+ + '<button class="dk-btn' + (hsub === 'kick' ? ' ok' : '') + '" data-hsub="kick">👢 踢人</button>'
3582
+ + '</div>'
3583
+ if (hsub === 'mute') {
3584
+ body += '<div class="dk-row">目标群: ' + groupSelHtml
3585
+ + '<button class="dk-btn" id="dk-refresh-mute">🔄 刷新</button></div>'
3586
+ body += '<div class="dk-msg">全员禁言: 官方接口未开放 · 只能操作普通成员(群主/管理员不可禁)</div>'
3587
+ body += '<div style="font-weight:700;font-size:13px;margin:8px 0 4px">① 禁言成员(机器人见过的)</div>'
3588
+ body += '<div class="dk-list" id="dk-member-list"></div>'
3589
+ body += '<div style="font-weight:700;font-size:13px;margin:8px 0 4px">② 正在禁言中</div>'
3590
+ body += '<div class="dk-list" id="dk-mute-list"></div>'
3591
+ } else {
3592
+ body += hammerKickHtml()
3593
+ }
3584
3594
  } else if (state.tab === 'roster') {
3585
3595
  body += '<div class="dk-row" style="font-weight:700;font-size:13px;margin:2px 0">📇 会话台账:机器人聊过的对象</div>'
3586
3596
  body += '<div class="dk-row"><span class="dk-msg" style="flex:1;line-height:1.5">👤 = 私聊过的人(有 c2c 会话,可直接发消息) · 👥 = 群(群成员≠私聊对象: 群里见过≠能私聊,只能群内@)</span></div>'
@@ -3931,7 +3941,15 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3931
3941
  if (state.tab === 'send') applyTargetFilter(panel, state)
3932
3942
  if (state.tab === 'chat') renderChatList()
3933
3943
  if (state.tab === 'join') renderJoinList()
3934
- if (state.tab === 'mute') { renderMemberList(); renderMuteList() }
3944
+ if (state.tab === 'mute') {
3945
+ if ((state.hammerSub || 'mute') === 'kick') {
3946
+ // 首次进踢人页:先查一次登录状态(qunLogged 为 null 表示还没查过,查完会被置成 true/false,不会循环)
3947
+ if (state.qunLogged === null) loadQunStatus(true)
3948
+ renderQunMemberList()
3949
+ } else {
3950
+ renderMemberList(); renderMuteList()
3951
+ }
3952
+ }
3935
3953
  if (state.tab === 'roster' || state.tab === 'send') { renderRosterList(); bindRosterEvents(); loadHubState(); loadBroadcastTasks(false, true) }
3936
3954
  // 📝 卡片: 必须在 paintBody 内绑定(2026-09-10 修复: 原在 tab 点击处先 bind 后 paintBody,
3937
3955
  // 绑的是即将被替换的旧 DOM → 发送/存为事件无反应、预览不渲染)
@@ -3946,6 +3964,141 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3946
3964
  if (state.tab === 'chat' && !state.chatItems.length && !state.chatBusy) loadChat(true)
3947
3965
  setTimeout(layoutPanel, 0)
3948
3966
  }
3967
+ // ── 🔨 锤子 · 踢人(2026-10-02 主人定)──
3968
+ // 登录态与 AI **共享**:两边读写同一个 {dataRoot}/qun-cookie.json,
3969
+ // 所以面板登录完 AI 立刻能用、AI 登录完面板也立刻显示已登录。
3970
+ function hammerKickHtml() {
3971
+ if (state.qunLogged !== true) {
3972
+ var h = '<div class="dk-msg">踢人需要先登录 QQ 群管理(扫码一次,之后长期免扫;登录态与 AI 共享)。</div>'
3973
+ h += '<div class="dk-row" style="gap:6px;align-items:center">'
3974
+ + '<button class="dk-btn ok" id="dk-qun-login"' + (state.qunBusy ? ' disabled' : '') + '>'
3975
+ + (state.qunBusy ? '⏳ 处理中…' : '📱 刷新二维码') + '</button>'
3976
+ + '<button class="dk-btn" id="dk-qun-status">🔄 查登录状态</button>'
3977
+ + '</div>'
3978
+ if (state.qunQr) {
3979
+ h += '<div class="dk-row" style="justify-content:center;margin:8px 0">'
3980
+ + '<img src="/api/qqbot-settings/qun-qr?t=' + Date.now() + '" alt="二维码" '
3981
+ + 'style="width:230px;height:230px;border:1px solid #e2d9ff;border-radius:8px;background:#fff;object-fit:contain">'
3982
+ + '</div>'
3983
+ + '<div class="dk-msg" style="text-align:center">用手机 QQ「扫一扫」,摄像头对着<b>这块屏幕</b>扫'
3984
+ + '(腾讯只认摄像头;相册 / 长按识别会被拒)</div>'
3985
+ }
3986
+ if (state.qunMsg) h += '<div class="dk-msg">' + esc(state.qunMsg) + '</div>'
3987
+ return h
3988
+ }
3989
+ var groups = state.qunGroups || []
3990
+ var opts = groups.map(function (g) {
3991
+ return '<option value="' + esc(g.gc) + '"' + (g.gc === state.qunGc ? ' selected' : '') + '>'
3992
+ + esc(g.gn || g.gc) + '(' + esc(g.role || '') + ')</option>'
3993
+ }).join('')
3994
+ var out = '<div class="dk-row" style="gap:6px;flex-wrap:wrap;align-items:center">'
3995
+ + '<span style="font-size:12px;color:#666">群:</span>'
3996
+ + '<select class="qqs-sel" id="dk-qun-gc" style="min-width:200px">' + (opts || '<option value="">(没有可管理的群)</option>') + '</select>'
3997
+ + '<input class="qqs-txt" id="dk-qun-kw" placeholder="uin 或昵称(留空=全量)" value="' + esc(state.qunKw || '') + '" style="width:170px">'
3998
+ + '<button class="dk-btn" id="dk-qun-search">🔍 搜索成员</button>'
3999
+ + '<button class="dk-btn no" id="dk-qun-logout" title="清掉本地凭据(下次要重新扫码)">🚪 退出登录</button>'
4000
+ + '</div>'
4001
+ out += '<div class="dk-msg">✅ 已登录 uin=' + esc(state.qunUin || '?')
4002
+ + (state.qunCount ? ' · 该群共 ' + state.qunCount + ' 人' : '') + '</div>'
4003
+ out += '<div class="dk-list" id="dk-qun-member-list"></div>'
4004
+ return out
4005
+ }
4006
+ function renderQunMemberList() {
4007
+ var box = panel.querySelector('#dk-qun-member-list')
4008
+ if (!box) return
4009
+ var ms = state.qunMembers || []
4010
+ if (state.qunLoading) { box.innerHTML = '<div class="dk-empty">加载中…</div>'; return }
4011
+ if (!ms.length) { box.innerHTML = '<div class="dk-empty">还没有成员数据 —— 点「🔍 搜索成员」拉取</div>'; return }
4012
+ box.innerHTML = ms.map(function (m) {
4013
+ return '<div style="display:flex;align-items:center;gap:8px;padding:4px 2px;border-bottom:1px solid #f0ebff">'
4014
+ + '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(m.nick || '(无昵称)') + '</span>'
4015
+ + '<span style="flex:none;font-family:monospace;font-size:11px;color:#888">' + esc(m.uin) + '</span>'
4016
+ + '<button class="dk-btn no" style="flex:none" data-kick="' + esc(m.uin) + '" data-knick="' + esc(m.nick || '') + '">👢 踢</button>'
4017
+ + '</div>'
4018
+ }).join('')
4019
+ box.querySelectorAll('button[data-kick]').forEach(function (btn) {
4020
+ btn.onclick = function () {
4021
+ var uin = btn.getAttribute('data-kick')
4022
+ var nick = btn.getAttribute('data-knick') || uin
4023
+ if (!window.confirm('确定把「' + nick + '」(uin ' + uin + ') 移出群 ' + state.qunGc + ' ?')) return
4024
+ doQunKick([uin])
4025
+ }
4026
+ })
4027
+ }
4028
+ function loadQunStatus(repaint) {
4029
+ api('qun/status').then(function (d) {
4030
+ state.qunLogged = !!(d && d.logged)
4031
+ if (d && d.uin) state.qunUin = d.uin
4032
+ if (d && d.groups && d.groups.length) {
4033
+ state.qunGroups = d.groups
4034
+ var has = d.groups.some(function (g) { return g.gc === state.qunGc })
4035
+ if (!state.qunGc || !has) state.qunGc = d.groups[0].gc
4036
+ }
4037
+ if (repaint) paintBody()
4038
+ }).catch(function () { state.qunLogged = false; if (repaint) paintBody() })
4039
+ }
4040
+ function doQunLogin() {
4041
+ state.qunBusy = true; state.qunMsg = ''
4042
+ paintBody()
4043
+ apiPost('qun/login', {}).then(function (d) {
4044
+ state.qunBusy = false
4045
+ if (d && d.logged) {
4046
+ state.qunLogged = true; state.qunUin = d.uin || ''
4047
+ state.qunMsg = '✅ 已登录(免扫码)'
4048
+ loadQunStatus(false); paintBody()
4049
+ return
4050
+ }
4051
+ state.qunQr = !!(d && d.needScan)
4052
+ state.qunMsg = (d && d.msg) || (state.qunQr ? '' : '没能生成二维码')
4053
+ paintBody()
4054
+ if (state.qunQr) startQunPoll()
4055
+ }).catch(function (e) { state.qunBusy = false; state.qunMsg = '异常: ' + e.message; paintBody() })
4056
+ }
4057
+ var qunPollTimer = null
4058
+ function startQunPoll() {
4059
+ if (qunPollTimer) return
4060
+ qunPollTimer = setInterval(function () {
4061
+ api('qun/poll').then(function (d) {
4062
+ if (d && d.logged) {
4063
+ clearInterval(qunPollTimer); qunPollTimer = null
4064
+ state.qunLogged = true; state.qunUin = d.uin || ''
4065
+ state.qunQr = false; state.qunMsg = '✅ 登录成功'
4066
+ loadQunStatus(false); paintBody()
4067
+ }
4068
+ }).catch(function () { /* ignore */ })
4069
+ }, 2500)
4070
+ }
4071
+ function doQunKick(uins) {
4072
+ state.qunMsg = '正在踢…'
4073
+ paintBody()
4074
+ apiPost('qun/kick', { gc: state.qunGc, uins: uins }).then(function (d) {
4075
+ var rs = (d && d.results) || []
4076
+ state.qunMsg = (d && d.msg ? d.msg : '完成') + ':' + rs.map(function (x) {
4077
+ return x.uin + (x.ec === 0 ? ' ✅' : ' ❌' + x.msg)
4078
+ }).join(';')
4079
+ loadQunMembers()
4080
+ }).catch(function (e) { state.qunMsg = '踢人异常: ' + e.message; paintBody() })
4081
+ }
4082
+ function loadQunMembers() {
4083
+ if (!state.qunGc) { state.qunMsg = '先选一个群'; paintBody(); return }
4084
+ state.qunLoading = true
4085
+ paintBody()
4086
+ // ⚠️ api(path, q) 的 q 是**字符串 query**,不是对象(传对象会变成 ?[object Object])
4087
+ var qs = 'gc=' + encodeURIComponent(state.qunGc) + '&q=' + encodeURIComponent(state.qunKw || '')
4088
+ api('qun/members', qs).then(function (d) {
4089
+ state.qunLoading = false
4090
+ if (d && d.ok) {
4091
+ state.qunMembers = d.members || []
4092
+ state.qunCount = d.count || 0
4093
+ state.qunMsg = '共拉取 ' + state.qunMembers.length + ' 人' + (d.note ? '(' + d.note + ')' : '')
4094
+ } else {
4095
+ state.qunMembers = []
4096
+ state.qunMsg = (d && d.error) || '加载失败'
4097
+ }
4098
+ paintBody()
4099
+ }).catch(function (e) { state.qunLoading = false; state.qunMsg = '异常: ' + e.message; paintBody() })
4100
+ }
4101
+
3949
4102
  function bindBodyEvents() {
3950
4103
  // ── 🚫 无上下文模式(2026-09-27)──
3951
4104
  // 照抄本文件既有模式:paintBody() 渲染完 → 调 bindBodyEvents() 统一绑事件
@@ -3955,6 +4108,40 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3955
4108
  var ctxOnBox = panel.querySelector('#dk-ctxless-on')
3956
4109
  if (ctxOnBox) ctxOnBox.onchange = function () { saveCtxless() }
3957
4110
  if (state.tab === 'session' && (state.stTab || 'lm') === 'cl') loadCtxless()
4111
+ // 🔨 锤子 · 子标签 + 踢人页按钮(2026-10-02)
4112
+ panel.querySelectorAll('[data-hsub]').forEach(function (sb) {
4113
+ sb.onclick = function () {
4114
+ state.hammerSub = sb.getAttribute('data-hsub')
4115
+ paintBody()
4116
+ }
4117
+ })
4118
+ var qLoginBtn = panel.querySelector('#dk-qun-login')
4119
+ if (qLoginBtn) qLoginBtn.onclick = function () { doQunLogin() }
4120
+ var qStatusBtn = panel.querySelector('#dk-qun-status')
4121
+ if (qStatusBtn) qStatusBtn.onclick = function () { state.qunMsg = '正在查询…'; paintBody(); loadQunStatus(true) }
4122
+ var qSearchBtn = panel.querySelector('#dk-qun-search')
4123
+ if (qSearchBtn) {
4124
+ qSearchBtn.onclick = function () {
4125
+ var kwEl = panel.querySelector('#dk-qun-kw')
4126
+ state.qunKw = kwEl ? kwEl.value : state.qunKw
4127
+ loadQunMembers()
4128
+ }
4129
+ }
4130
+ var qGcSel = panel.querySelector('#dk-qun-gc')
4131
+ if (qGcSel) {
4132
+ qGcSel.onchange = function () { state.qunGc = qGcSel.value; loadQunMembers() }
4133
+ }
4134
+ var qLogoutBtn = panel.querySelector('#dk-qun-logout')
4135
+ if (qLogoutBtn) {
4136
+ qLogoutBtn.onclick = function () {
4137
+ if (!window.confirm('清掉本地登录凭据?下次要重新扫码。')) return
4138
+ apiPost('qun/logout', {}).then(function () {
4139
+ state.qunLogged = false; state.qunUin = ''; state.qunMembers = []
4140
+ state.qunGroups = []; state.qunGc = ''; state.qunMsg = '已退出登录'
4141
+ paintBody()
4142
+ }).catch(function () { state.qunLogged = false; paintBody() })
4143
+ }
4144
+ }
3958
4145
  var tbs = panel.querySelectorAll('.dk-tab button')
3959
4146
  tbs.forEach(function (btn) {
3960
4147
  btn.onclick = function () {
@@ -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;AAI1C,eAAO,MAAM,MAAM,UAAY,CAAC;AAWhC,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 || '';
@@ -112,8 +114,9 @@ function getContextlessSmartGlobal() { return ctxSmartGlobal; }
112
114
  /** 无上下文·智能判断:取当前会话的 dataRoot + sessionKey */
113
115
  async function resolveCtxMemoEnv(exec) {
114
116
  // ★ 优先用注册时记住的会话坐标(工具里的 exec.agent 与 pre-step 的 agent 是两个不同对象,反查常失败)
117
+ // ⚠️ 必须要求 sessionKey 非空:备忘/压缩都按会话分文件存,空 key 会让所有会话串到同一份。
115
118
  const cached = ctxEnvOf(exec);
116
- if (cached)
119
+ if (cached && cached.sessionKey)
117
120
  return cached;
118
121
  try {
119
122
  const got = findSessionRec(channelOf(exec), exec);
@@ -127,6 +130,24 @@ async function resolveCtxMemoEnv(exec) {
127
130
  return undefined;
128
131
  }
129
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
+ }
130
151
  /** 登记文件路径(工具只登记,真正压缩在下一回合 pre-step) */
131
152
  function ctxPendingPath(dataRoot, sessionKey) {
132
153
  // ⚠️ 同上:不能保留冒号(Windows 文件名禁用 `:`,会导致 pending 写不进/读不到)
@@ -2544,6 +2565,162 @@ var contextDropTool = defineTool({
2544
2565
  + (a.note ? '(要点已存进你的备忘)' : '(本次没写要点;丢弃后这些历史就找不回来了,建议带 note)') };
2545
2566
  },
2546
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
+ });
2547
2724
  // 模块级直接拿(不依赖 apply —— apply 只在会话首建时跑,agent 重建后不会重跑)
2548
2725
  // 注意:三个工具定义在同文件下方(var 声明,有提升),故这里用取值函数避免 TDZ。
2549
2726
  /**
@@ -2567,6 +2744,17 @@ function ctxToolsRef() {
2567
2744
  }
2568
2745
  return CTX_TOOLS_REF;
2569
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
+ }
2570
2758
  const ctxEnvMap = new WeakMap();
2571
2759
  /** 工具执行时取会话坐标:优先用注册时记下的(最准),取不到再回退反查 */
2572
2760
  function ctxEnvOf(exec) {
@@ -2581,6 +2769,18 @@ function ctxEnvOf(exec) {
2581
2769
  catch { /* fallthrough */ }
2582
2770
  return undefined;
2583
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
+ }
2584
2784
  /**
2585
2785
  * 按会话开关**动态**注册/注销三个上下文工具(context_memo / context_compact / context_drop)。
2586
2786
  * 由 session-manager 在每个回合的 pre-step 里调用(拿 rec.agentCtx)。
@@ -2594,8 +2794,10 @@ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2594
2794
  const key = agentCtx;
2595
2795
  try {
2596
2796
  // ① 先记会话坐标(每次 pre-step 都刷新;这是工具执行时唯一的可靠来源)
2597
- if (env && env.dataRoot && env.sessionKey) {
2598
- 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 ?? '') });
2599
2801
  }
2600
2802
  const cur = ctxToolDisposers.get(key);
2601
2803
  // ⚠️ 2026-10-02:不能靠"缓存里已注册"就跳过 —— agent/ctx 一变,实际工具表就作废了,
@@ -2610,11 +2812,12 @@ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2610
2812
  }
2611
2813
  ctxToolDisposers.delete(key);
2612
2814
  }
2815
+ const reg = ctxObj.tools?.register;
2816
+ if (typeof reg !== 'function')
2817
+ return;
2818
+ const ds = [];
2819
+ // ① 三个上下文工具:只在「无上下文·智能判断」开启时挂
2613
2820
  if (shouldHave) {
2614
- const reg = ctxObj.tools?.register;
2615
- if (typeof reg !== 'function')
2616
- return;
2617
- const ds = [];
2618
2821
  for (const t of ctxToolsRef()) {
2619
2822
  try {
2620
2823
  const d = reg.call(ctxObj.tools, t);
@@ -2623,10 +2826,25 @@ export function syncContextTools(agentCtx, shouldHave, env, reminderText) {
2623
2826
  }
2624
2827
  catch { /* 单个工具注册失败不影响其他 */ }
2625
2828
  }
2626
- if (ds.length)
2627
- ctxToolDisposers.set(key, ds);
2628
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));
2629
2848
  }
2630
- catch { /* fail-soft:工具挂载失败绝不影响会话 */ }
2631
2849
  }
2632
2850
  //# sourceMappingURL=channel-tools.js.map