@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.
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.2)**
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.2)
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
  // 顺带修掉"换个浏览器分组就没了"的老毛病。
@@ -2073,6 +2073,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2073
2073
  state.ctxless = d || null
2074
2074
  if (on) on.checked = !!(d && d.enabled)
2075
2075
  if (win && d && typeof d.window === 'number') win.value = String(d.window)
2076
+ try { var _mmEl = document.getElementById('dk-ctxless-memomax'); if (_mmEl) _mmEl.value = String((d && typeof d.memoMax === 'number') ? d.memoMax : 20) } catch (e) { /* ignore */ }
2076
2077
  // 2026-10-01: 同步 radio(原来只更新了条数框 —— 面板先渲染后拉数据,radio 会停在默认的"按条数")
2077
2078
  try {
2078
2079
  var _isSmart = !!(d && d.smart === true)
@@ -2099,7 +2100,8 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2099
2100
  // 2026-10-01: 模式单选 → smart;smart=true 时 window 不生效(互斥)
2100
2101
  var _modeEl = document.querySelector('input[name="dk-ctxless-mode"]:checked')
2101
2102
  var _mode = _modeEl ? String(_modeEl.value) : 'window'
2102
- var body = { sessionKey: key, enabled: !!(on && on.checked), window: win ? (parseInt(win.value, 10) || 0) : 5, smart: _mode === 'smart' }
2103
+ var _mmEl2 = document.getElementById('dk-ctxless-memomax')
2104
+ var body = { sessionKey: key, enabled: !!(on && on.checked), window: win ? (parseInt(win.value, 10) || 0) : 5, smart: _mode === 'smart', memoMax: _mmEl2 ? (parseInt(_mmEl2.value, 10) || 20) : 20 }
2103
2105
  // radio 切换:智能判断时条数框禁用(互斥的视觉提示)
2104
2106
  Array.prototype.forEach.call(document.querySelectorAll('input[name="dk-ctxless-mode"]'), function (r) {
2105
2107
  r.onchange = function () {
@@ -2114,6 +2116,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
2114
2116
  api('contextless?sessionKey=' + encodeURIComponent(key)).then(function (back) {
2115
2117
  var same = back && back.enabled === body.enabled && Number(back.window) === Number(body.window)
2116
2118
  && (back.smart === true) === (body.smart === true)
2119
+ && Number(back.memoMax || 20) === Number(body.memoMax || 20)
2117
2120
  if (hint) {
2118
2121
  hint.textContent = same
2119
2122
  ? ('✅ 已保存并回读确认: ' + (back.enabled ? '开启' : '关闭') + ' / 带 ' + back.window + ' 条')
@@ -3485,7 +3488,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3485
3488
  + '<button data-t="roster" class="' + (state.tab === 'roster' ? 'on' : '') + '">📇 群组管理</button>'
3486
3489
  + '<button data-t="chat" class="' + (state.tab === 'chat' ? 'on' : '') + '">💬 聊天</button>'
3487
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>'
3488
- + '<button data-t="mute" class="' + (state.tab === 'mute' ? 'on' : '') + '">🔇 禁言</button>'
3491
+ + '<button data-t="mute" class="' + (state.tab === 'mute' ? 'on' : '') + '">🔨 锤子</button>'
3489
3492
  + '<button data-t="out" class="' + (state.tab === 'out' ? 'on' : '') + '">⚙️ 出站</button>'
3490
3493
  + '<button data-t="send" class="' + (['send','broadcast','bp','card'].indexOf(state.tab) >= 0 ? 'on' : '') + '">📤 群发</button>'
3491
3494
  + '<button data-t="session" class="' + (state.tab === 'session' ? 'on' : '') + '">⚙ 单会话设置</button>'
@@ -3571,13 +3574,23 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3571
3574
  + '<span class="dk-msg">机器人需为群管理员; 通过/拒绝直接生效</span></div>'
3572
3575
  body += '<div class="dk-list" id="dk-join-list"></div>'
3573
3576
  } else if (state.tab === 'mute') {
3574
- body += '<div class="dk-row">目标群: ' + groupSelHtml
3575
- + '<button class="dk-btn" id="dk-refresh-mute">🔄 刷新</button></div>'
3576
- body += '<div class="dk-msg">全员禁言: 官方接口未开放 · 只能操作普通成员(群主/管理员不可禁)</div>'
3577
- body += '<div style="font-weight:700;font-size:13px;margin:8px 0 4px">① 禁言成员(机器人见过的)</div>'
3578
- body += '<div class="dk-list" id="dk-member-list"></div>'
3579
- body += '<div style="font-weight:700;font-size:13px;margin:8px 0 4px">② 正在禁言中</div>'
3580
- 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
+ }
3581
3594
  } else if (state.tab === 'roster') {
3582
3595
  body += '<div class="dk-row" style="font-weight:700;font-size:13px;margin:2px 0">📇 会话台账:机器人聊过的对象</div>'
3583
3596
  body += '<div class="dk-row"><span class="dk-msg" style="flex:1;line-height:1.5">👤 = 私聊过的人(有 c2c 会话,可直接发消息) · 👥 = 群(群成员≠私聊对象: 群里见过≠能私聊,只能群内@)</span></div>'
@@ -3685,6 +3698,10 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3685
3698
  + '<span>智能判断(推荐)'
3686
3699
  + '<div style="font-size:12px;color:#888;line-height:1.4">由 AI <b>每轮自己判断话题是否结束</b>:结束了它就压缩旧历史(可留最近几条)或整段丢弃,<b>只留下自己总结的要点</b> —— 省 token 最狠,也不容易断片。要点存在独立文件里,<b>压缩永远碰不到</b>。</div></span></label>'
3687
3700
  + '</div>'
3701
+ // 备忘条数上限(面板可设;超出自动丢最旧)
3702
+ body += '<div class="dk-row" style="gap:6px;align-items:center;margin:4px 0 2px 22px;font-size:13px">'
3703
+ + '<span>备忘条数上限 <input type="number" id="dk-ctxless-memomax" min="1" max="500" value="' + ((state.ctxless && typeof state.ctxless.memoMax === 'number') ? state.ctxless.memoMax : 20) + '" style="width:60px"> 条</span>'
3704
+ + '<span style="font-size:12px;color:#888">(超出自动丢最旧的;AI 每轮的提示已要求它写精简)</span></div>'
3688
3705
  body += '<div class="dk-msg" style="line-height:1.5;color:#888">0 条 = 完全不带群消息(只剩系统规则 + 当前这句)。智能判断模式下条数不生效。开关存在插件数据目录, 重启不丢;<b>面板保存即时生效</b>,直接改文件则需重启。</div>'
3689
3706
  } else if (st === 'blank') {
3690
3707
  body += '<div style="border:1px dashed #cfc7ee;border-radius:8px;padding:20px 14px;margin:8px 0;text-align:center;line-height:2">'
@@ -3924,7 +3941,15 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3924
3941
  if (state.tab === 'send') applyTargetFilter(panel, state)
3925
3942
  if (state.tab === 'chat') renderChatList()
3926
3943
  if (state.tab === 'join') renderJoinList()
3927
- 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
+ }
3928
3953
  if (state.tab === 'roster' || state.tab === 'send') { renderRosterList(); bindRosterEvents(); loadHubState(); loadBroadcastTasks(false, true) }
3929
3954
  // 📝 卡片: 必须在 paintBody 内绑定(2026-09-10 修复: 原在 tab 点击处先 bind 后 paintBody,
3930
3955
  // 绑的是即将被替换的旧 DOM → 发送/存为事件无反应、预览不渲染)
@@ -3939,6 +3964,141 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3939
3964
  if (state.tab === 'chat' && !state.chatItems.length && !state.chatBusy) loadChat(true)
3940
3965
  setTimeout(layoutPanel, 0)
3941
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
+
3942
4102
  function bindBodyEvents() {
3943
4103
  // ── 🚫 无上下文模式(2026-09-27)──
3944
4104
  // 照抄本文件既有模式:paintBody() 渲染完 → 调 bindBodyEvents() 统一绑事件
@@ -3948,6 +4108,40 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3948
4108
  var ctxOnBox = panel.querySelector('#dk-ctxless-on')
3949
4109
  if (ctxOnBox) ctxOnBox.onchange = function () { saveCtxless() }
3950
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
+ }
3951
4145
  var tbs = panel.querySelectorAll('.dk-tab button')
3952
4146
  tbs.forEach(function (btn) {
3953
4147
  btn.onclick = function () {
@@ -4552,18 +4746,18 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
4552
4746
  var keyOf = function (x) { return String(x.seq) + '|' + String(x.dir) + '|' + String(x.text || '').slice(0, 24) }
4553
4747
  var have = {}
4554
4748
  state.chatItems.forEach(function (x) { have[keyOf(x)] = 1 })
4555
- // ⚠️ 2026-10-01 主人实测「向下滚后最新消息后面冒出历史消息」:
4556
- // 群打包块([历史]…[/历史])会被拆成多条挂在**同一个新 seq** 上, 内容却是**更早**的消息;
4557
- // 不过滤就会被当新事件整块接到列表尾部。只收"时间不早于列表尾部那条"的条目。
4558
- var _tailT = state.chatItems.length ? (Number(state.chatItems[state.chatItems.length - 1].time) || 0) : 0
4559
- var fresh = list.filter(function (x) {
4560
- return !have[keyOf(x)] && (Number(x.time) || 0) >= _tailT
4749
+ // ⚠️ 2026-10-01 主人实测「向下滚后最新消息后面冒出历史消息」:
4750
+ // 群打包块([历史]…[/历史])会被拆成多条挂在**同一个新 seq** 上, 内容却是**更早**的消息;
4751
+ // 不过滤就会被当新事件整块接到列表尾部。只收"时间不早于列表尾部那条"的条目。
4752
+ var _tailT = state.chatItems.length ? (Number(state.chatItems[state.chatItems.length - 1].time) || 0) : 0
4753
+ var fresh = list.filter(function (x) {
4754
+ return !have[keyOf(x)] && (Number(x.time) || 0) >= _tailT
4561
4755
  })
4562
4756
  if (fresh.length) {
4563
4757
  state.chatItems = state.chatItems.concat(fresh)
4564
4758
  appendChatRows(fresh) // ★ 增量追加: 不重绘、不闪、不跳位置
4565
4759
  }
4566
- // 已加载位置 = 列表里最大的事件 seq(同 ①:不能用 tailSeq)
4760
+ // 已加载位置 = 列表里最大的事件 seq(同 ①:不能用 tailSeq)
4567
4761
  state.chatItems.forEach(function (x) { var n = Number(x.seq); if (n > state.chatNewest) state.chatNewest = n })
4568
4762
  updateChatStatus()
4569
4763
  }).catch(function () { state.chatBusy = ''; updateChatStatus() })