@zaofan/dsh-qqbot 1.6.5 → 1.6.7

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.
Files changed (38) hide show
  1. package/README.md +66 -0
  2. package/README_EN.md +23 -0
  3. package/client/qqbot-settings.js +281 -9
  4. package/dist/api/group-admin.d.ts +26 -0
  5. package/dist/api/group-admin.d.ts.map +1 -1
  6. package/dist/api/group-admin.js +38 -0
  7. package/dist/api/group-admin.js.map +1 -1
  8. package/dist/channel-tools.d.ts +45 -0
  9. package/dist/channel-tools.d.ts.map +1 -1
  10. package/dist/channel-tools.js +77 -10
  11. package/dist/channel-tools.js.map +1 -1
  12. package/dist/config.d.ts +7 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +3 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/features/botplay-ext.d.ts +269 -0
  17. package/dist/features/botplay-ext.d.ts.map +1 -0
  18. package/dist/features/botplay-ext.js +213 -0
  19. package/dist/features/botplay-ext.js.map +1 -0
  20. package/dist/features/botplay.d.ts +152 -21
  21. package/dist/features/botplay.d.ts.map +1 -1
  22. package/dist/features/botplay.js +706 -21
  23. package/dist/features/botplay.js.map +1 -1
  24. package/dist/features/extension-store.d.ts +49 -0
  25. package/dist/features/extension-store.d.ts.map +1 -1
  26. package/dist/features/extension-store.js +90 -0
  27. package/dist/features/extension-store.js.map +1 -1
  28. package/dist/features/session-registry.d.ts +17 -0
  29. package/dist/features/session-registry.d.ts.map +1 -1
  30. package/dist/features/session-registry.js +24 -0
  31. package/dist/features/session-registry.js.map +1 -1
  32. package/dist/gateway/bootstrap.d.ts.map +1 -1
  33. package/dist/gateway/bootstrap.js +15 -1
  34. package/dist/gateway/bootstrap.js.map +1 -1
  35. package/docs/USER-GUIDE.md +230 -27
  36. package/docs/botplay-contract.md +92 -0
  37. package/package.json +2 -1
  38. package/settings-host.js +4533 -4245
package/README.md CHANGED
@@ -71,6 +71,30 @@ QQ 官方**没有开放踢人接口**,所以这一块走"你自己的 QQ 号 +
71
71
 
72
72
  ---
73
73
 
74
+ ## 🆚 官方 Bot 方案 vs onbot / NapCat 等第三方协议端
75
+
76
+ 本插件走的是**腾讯官方 QQ 机器人开放平台**(AppID + AppSecret + 官方 SDK),
77
+ **不是**"模拟 QQ 客户端协议"那条路。两者差别不小,先把话说清楚:
78
+
79
+ | | **本插件(官方 Bot API)** | 第三方协议端(onbot / NapCat / Lagrange…) |
80
+ |---|---|---|
81
+ | **登录方式** | 填 **AppID / AppSecret** 即可,**不扫码、不登录你的真人 QQ 号** | 扫码登录**你的个人 QQ 号**,常掉线、要保活 |
82
+ | **账号安全** | ✅ **完全不碰你的个人号**,无封号风险 | ⚠️ **有封号风险**:协议模拟违反 QQ 用户协议,腾讯明确禁止 |
83
+ | **稳定性** | ✅ 官方接口,腾讯自己在维护;变更会提前公告 | ⚠️ 协议一变就挂,得等签名服务(qsign 之类)跟进 |
84
+ | **资源占用** | ✅ **极低**:纯 HTTP + WebSocket,无浏览器、无协议端常驻 | ⚠️ 高:要挂 QQ 客户端 / 协议端,内存几百 MB 起 |
85
+ | **低配 / 手机部署** | ✅ **能跑**:安卓 Termux、树莓派、1 核 1G 的小机器都行(见下方「📱 装到手机上」) | ⚠️ 难:协议端对架构/内存有要求,安卓上尤其折腾 |
86
+ | **隐私** | ✅ 只与腾讯官方通信,没有中间人 | ⚠️ 要经过第三方协议实现 |
87
+ | **功能范围** | ⚠️ **受官方开放范围限制**(例:**踢人接口官方没开放**) | ✅ 全功能 |
88
+
89
+ **关于"功能受限",本插件的态度**:官方没开放的,能补就补、不能补就明说 ——
90
+ 例如**踢人**:官方接口至今未开放,插件用 **🔨 锤子** 面板让你**用自己的号**走群官网接口把这块补上
91
+ (扫码一次、之后长期免扫,登录态还会和 AI 共享,详见下文)。
92
+
93
+ > **一句话**:想要**安全、省心、还能塞进手机或小机器**,就选官方 Bot 方案;
94
+ > 只有当你非要第三方协议端的"全家桶"、并且愿意承担封号风险时,才另说。
95
+
96
+ ---
97
+
74
98
  ## 安装
75
99
 
76
100
  > ## ⚠️ 必须装到 `web` profile
@@ -273,6 +297,48 @@ dsh: disabling profile plugin row "mcp-chrome": Plugin ... is incompatible with
273
297
 
274
298
  想强行运行,可用官方提供的**确切版本例外**(`dsh plugin allow-version`,或插件管理页里授予),但官方警告"可能崩溃或数据丢失",自行权衡。
275
299
 
300
+ ## 🤖 自定义事件 + 「让 AI 写」(v1.6.7)
301
+
302
+ 普通的互动卡片只能「点按钮 → 回一句固定文本」,能力被编辑器框死。
303
+ 这一版让卡片**变成一个真的 JS 模块**(可以叫 AI 帮你写),于是它能做到编辑器做不到的事:
304
+
305
+ - **自己维护状态**:谁点过、点了几次、本轮名单 —— 落盘、升级不丢
306
+ - **自己判权限**:QQ 的按钮权限是"整张卡片一个",但模块里能用 `ctx.user.isOwner` 做到"这个按钮只有主人能点"
307
+ - **自己发消息**:文本 / markdown / 图片,还能 @ 某人
308
+ - **调 AI**:`ctx.appendSilent()` 静默记进上下文;或 `ctx.appendWake()` **把 AI 叫起来**
309
+ - **调官方 API**:模块拿到 `ctx.appId` / `ctx.appSecret`,自己换 token 就能 fetch 任意官方接口
310
+
311
+ ### 📸 长什么样
312
+
313
+ **① 面板:事件列表 + 「让 AI 写」+ 自定义事件信息区**
314
+
315
+ ![自定义事件面板](docs/screenshots/botplay-custom-event-panel.png)
316
+
317
+ **② 效果:签到统计 + 结束后唤醒 AI(AI 自己开口总结)**
318
+
319
+ ![唤醒 AI 演示](docs/screenshots/botplay-wake-ai-demo.png)
320
+
321
+ > 上面第二张就是"第三方框架做不到的事":**卡片点完后 AI 也知道了结果**,
322
+ > 而且它会顺着群里的气氛自己说人话 —— 那段「带头营业(尾巴摇摇)」不是模板文案,是 AI 现场说的。
323
+
324
+ ### 怎么用
325
+
326
+ 1. 面板 → 📤 群发 → **互动事件** → 底部「🤖 让 AI 写自定义事件」输入框
327
+ 2. 用大白话写你要什么,例如:
328
+ > 我想写一个签到人数统计,有两个按钮,按钮1是签到,所有人都能点且每个人只能点一次;
329
+ > 按钮2是结束,只能我点。我点结束后,bot 发送消息到 QQ 上,统计签到人数和所有签到者的名称
330
+ 3. 点 **🤖 让 AI 写** —— 面板会把「你的需求 + 官方文档链接 + 契约 + 环境信息」发进当前会话;
331
+ AI 按契约写一个 `.mjs` 放进 `{dataRoot}/.qqbot-extensions/botplay/`,并登记到 `botplay-events.json`
332
+ 4. 改完模块后,面板点 **🔄 重载模块** 即生效(**不用重启**)
333
+ 5. 想让它回到普通编辑器:把事件 JSON 里的 `file` 字段删掉即可
334
+
335
+ ### 契约在哪
336
+
337
+ `docs/botplay-contract.md` —— **纯文本、可直接改**(改完重启宿主生效)。
338
+ 它定义了模块导出哪些钩子、`ctx` 有哪些能力(含上面那几条),也是「让 AI 写」时发给 AI 的那份说明。
339
+
340
+ ---
341
+
276
342
  ## 🔨 锤子面板:禁言 / 踢人(v1.6.3)
277
343
 
278
344
  dock 面板里的 **🔨 锤子**(原来叫「🔇 禁言」),下面分两页:
package/README_EN.md CHANGED
@@ -91,6 +91,29 @@ QQ User → QQ WebSocket → dsh-im-qqbot → ctx.agents → dsh agent loop →
91
91
  (assistant reply → QQ sendMarkdown)
92
92
  ```
93
93
 
94
+ ## 🆚 Official Bot API vs third-party protocol bridges (onbot / NapCat / …)
95
+
96
+ This plugin uses the **official Tencent QQ Bot Open Platform** (AppID + AppSecret + the official SDK),
97
+ **not** the "simulate a QQ client protocol" route. The difference is not cosmetic:
98
+
99
+ | | **This plugin (Official Bot API)** | Third-party bridges (onbot / NapCat / Lagrange…) |
100
+ |---|---|---|
101
+ | **Login** | Just paste **AppID / AppSecret** — **no QR scan, your personal QQ is never logged in** | Scan a QR code to log in **your personal QQ account**; drops often, needs keep-alive |
102
+ | **Account safety** | ✅ **Never touches your personal account** — no ban risk | ⚠️ **Ban risk**: protocol simulation violates QQ's ToS and is explicitly forbidden |
103
+ | **Stability** | ✅ Official API maintained by Tencent; breaking changes are announced ahead | ⚠️ Breaks whenever the protocol shifts; must wait for signature services (qsign etc.) |
104
+ | **Footprint** | ✅ **Very low**: plain HTTP + WebSocket — no browser, no resident client | ⚠️ Heavy: a QQ client / bridge must stay resident, hundreds of MB of RAM |
105
+ | **Low-spec / mobile** | ✅ **Runs anywhere**: Android (Termux), Raspberry Pi, a 1-core / 1 GB box | ⚠️ Hard: bridges have arch/RAM requirements, especially painful on Android |
106
+ | **Privacy** | ✅ Talks only to Tencent's official endpoints — no middleman | ⚠️ Traffic passes through a third-party implementation |
107
+ | **Feature coverage** | ⚠️ **Limited to what the official platform opens** (e.g. **kicking is not exposed**) | ✅ Everything |
108
+
109
+ **About "limited coverage"** — we patch what we can, and say so plainly when we can't.
110
+ Example: **kicking members**. The official API still doesn't expose it, so the plugin adds a **🔨 Hammer**
111
+ panel where **your own account** calls the group website API to fill the gap
112
+ (scan once, then it stays logged in; the credential is shared with the AI — see below).
113
+
114
+ > **TL;DR**: want **safe, low-maintenance, deployable on a phone or a tiny box**? Go official.
115
+ > Only pick a third-party bridge if you genuinely need its full toolkit and accept the ban risk.
116
+
94
117
  ## Installation
95
118
 
96
119
  > ⚠️ **Install into the `web` profile** (host of the `dsh web` settings panel); other profiles get a bare
@@ -1718,7 +1718,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
1718
1718
  // · 群管理(发消息/审批入群/禁言) → 悬浮球点开成操作台
1719
1719
  var DOCK_CSS = "#qqs-dock-wrap{position:fixed;right:18px;bottom:190px;z-index:9997;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif}#qqs-dock-ball{width:52px;height:52px;border-radius:50%;background:linear-gradient(160deg,#7c6cf0,#5b4fd8);color:#fff;font-size:24px;line-height:52px;text-align:center;cursor:pointer;box-shadow:0 6px 20px rgba(90,70,220,.4);user-select:none;transition:transform .12s,box-shadow .12s;position:relative}#qqs-dock-ball:hover{transform:scale(1.06)}#qqs-dock-badge{position:absolute;top:-4px;right:-4px;min-width:18px;height:18px;border-radius:9px;background:#ff4d4f;color:#fff;font-size:11px;font-weight:700;line-height:18px;padding:0 4px;box-sizing:border-box;text-align:center;display:none}#qqs-dock-panel{position:fixed;right:18px;bottom:190px;z-index:9998;width:min(720px,94vw);max-height:68vh;display:none;flex-direction:column;background:#fff;border:1px solid #d9c6ff;border-radius:16px;box-shadow:0 12px 40px rgba(60,40,140,.25);overflow:hidden;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif}#qqs-dock-panel .dk-h{display:flex;align-items:center;gap:8px;padding:10px 14px;background:linear-gradient(90deg,#7c6cf01f,#7c6cf008);font-size:14px;font-weight:700;color:#4a3a9f;border-bottom:1px solid #efe8ff}#qqs-dock-panel .dk-b{padding:10px 14px;overflow:auto;font-size:13px;color:#1f2329}#qqs-dock-panel .dk-tab{display:flex;gap:4px;border-bottom:1px solid #eee;margin-bottom:10px}#qqs-dock-panel .dk-tab button{font:inherit;font-size:13px;padding:6px 14px;border:none;background:none;cursor:pointer;color:#666;border-bottom:2px solid transparent}#qqs-dock-panel .dk-tab button.on{color:#4a3a9f;font-weight:700;border-bottom-color:#7c6cf0}#qqs-dock-panel select.qqs-sel,#qqs-dock-panel input.qqs-txt,#qqs-dock-panel textarea.qqs-txt{font:inherit;color:#1f2329;background:#fff;border:1px solid #d0d5dd;border-radius:8px;padding:5px 8px;outline:none}#qqs-dock-panel .dk-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0}#qqs-dock-panel .dk-btn{font:inherit;font-size:13px;padding:5px 12px;border-radius:8px;cursor:pointer;border:1px solid #d9c6ff;background:#f1ecff;color:#4a3a9f}#qqs-dock-panel .dk-btn.ok{background:#e6f7ec;color:#187a3d;border-color:#b8e6c8}#qqs-dock-panel .dk-btn.no{background:#fdeeee;color:#c23131;border-color:#f3c4c4}#qqs-dock-panel .dk-btn:disabled{opacity:.5;cursor:default}#qqs-dock-panel .dk-msg{color:#888;font-size:12px;padding:2px 0}#qqs-dock-panel .dk-list{max-height:34vh;overflow:auto;border:1px solid #f0ecff;border-radius:10px;padding:4px}#qqs-dock-panel .dk-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #f5f2ff;flex-wrap:wrap;font-size:13px}#qqs-dock-panel .dk-item:last-child{border-bottom:none}#qqs-dock-panel .dk-empty{color:#aaa;text-align:center;padding:18px 0;font-size:12px}"
1720
1720
  // ── 💬 聊天视图样式(dock 追加段, 2026-09-07): QQ 风格气泡, 群友左(bot)右 ──
1721
- var DOCK_CSS2 = "#qqs-dock-panel .dk-chat-head{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:0 0 6px}#qqs-dock-panel .dk-chat-box{overflow-y:auto;overscroll-behavior:contain;background:#f5f6f8;border:1px solid #e6e8ec;border-radius:10px;padding:10px 12px;box-sizing:border-box;height:min(36vh,300px);min-height:140px;scroll-behavior:auto}#qqs-dock-panel .dk-chat-box::-webkit-scrollbar{width:6px}#qqs-dock-panel .dk-chat-box::-webkit-scrollbar-thumb{background:#d3d7dd;border-radius:3px}#qqs-dock-panel .dk-crow{display:flex;gap:8px;align-items:flex-start;margin:0 0 12px}#qqs-dock-panel .dk-crow.out{flex-direction:row-reverse}#qqs-dock-panel .dk-ava{width:32px;height:32px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;color:#fff;overflow:hidden;user-select:none;background:linear-gradient(150deg,#8fb3e8,#5f8fd9)}#qqs-dock-panel .dk-crow.out .dk-ava{background:linear-gradient(150deg,#5ec7f2,#3b8fe0)}#qqs-dock-panel .dk-cmain{display:flex;flex-direction:column;max-width:calc(100% - 40px);min-width:0}#qqs-dock-panel .dk-crow.in .dk-cmain{align-items:flex-start}#qqs-dock-panel .dk-crow.out .dk-cmain{align-items:flex-end}#qqs-dock-panel .dk-cmeta{font-size:11px;color:#9aa0a8;margin:0 6px 2px;max-width:100%;display:flex;align-items:center;gap:5px;flex-wrap:wrap}#qqs-dock-panel .dk-crow.out .dk-cmeta{flex-direction:row-reverse}#qqs-dock-panel .dk-cbubble{padding:7px 11px;font-size:13px;line-height:1.55;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;box-shadow:0 1px 2px rgba(20,30,60,.06);max-width:100%}#qqs-dock-panel .dk-cbubble a.dk-link{color:#1a66d6;text-decoration:underline;word-break:break-all;cursor:pointer}#qqs-dock-panel .dk-crow.out .dk-cbubble a.dk-link{color:#eaf3ff}#qqs-dock-panel .dk-crow.in .dk-cbubble{background:#fff;border:1px solid #e3e6ea;color:#1f2329;border-radius:3px 10px 10px 10px}#qqs-dock-panel .dk-crow.out .dk-cbubble{background:linear-gradient(180deg,#69a6ff,#3d7df5);color:#fff;border-radius:10px 3px 10px 10px}#qqs-dock-panel .dk-img{display:block;max-width:min(230px,52vw);max-height:200px;border-radius:6px;margin:0 0 3px;object-fit:cover;cursor:zoom-in}#qqs-dock-panel .dk-audio{display:block;max-width:min(260px,60vw);width:100%;height:34px;margin:0 0 2px}#qqs-dock-panel .dk-file{display:inline-flex;align-items:center;gap:5px;max-width:100%;padding:6px 12px;border-radius:8px;background:#f0f6ff;border:1px solid #cfe0fa;color:#2b6bd8;font-size:13px;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#qqs-dock-panel .dk-file:hover{background:#e2edff}#qqs-dock-panel .dk-video{display:block;max-width:min(260px,60vw);max-height:180px;border-radius:6px;margin:0 0 2px}#qqs-dock-panel .dk-ctag{display:inline-block;font-size:10px;color:#5b8ff0;background:#eaf2ff;border:1px solid #d4e3fd;border-radius:8px;padding:0 6px}#qqs-dock-panel .dk-chat-top{text-align:center;color:#b0b4bb;font-size:11px;padding:2px 0 6px;user-select:none}#qqs-dock-panel .dk-chat-bottom{text-align:center;color:#c3c7cd;font-size:11px;padding:6px 0 0}#qqs-dock-panel .dk-composer{margin-top:8px;border:1px solid #e3e6ea;border-radius:10px;background:#fff;overflow:visible}#qqs-dock-panel .dk-composer textarea{width:100%;box-sizing:border-box;border:none;outline:none;resize:none;font:inherit;font-size:13px;color:#1f2329;background:transparent;padding:8px 10px 4px;line-height:1.5;max-height:120px}#qqs-dock-panel .dk-cbar{display:flex;align-items:center;gap:4px;padding:4px 8px 6px;flex-wrap:wrap}#qqs-dock-panel .dk-cbar .dk-btn{padding:3px 10px;font-size:12px;border-radius:7px}#qqs-dock-panel .dk-cbar .dk-send{background:linear-gradient(180deg,#69a6ff,#3d7df5);color:#fff;border:none;border-radius:8px;padding:5px 18px;font-size:13px;font-weight:600;cursor:pointer}#qqs-dock-panel .dk-cbar .dk-send:disabled{opacity:.5;cursor:default}#qqs-lightbox{position:fixed;inset:0;z-index:2147483000;background:rgba(8,10,18,.82);display:flex;align-items:center;justify-content:center;cursor:zoom-out}#qqs-lightbox img{max-width:92vw;max-height:92vh;border-radius:8px;box-shadow:0 10px 60px rgba(0,0,0,.6)}#qqs-dock-panel .dk-composer{position:relative}#qqs-dock-panel .dk-at-pop{position:absolute;left:6px;bottom:calc(100% - 4px);z-index:30;min-width:200px;max-width:90%;max-height:190px;overflow-y:auto;background:#fff;border:1px solid #e0e4ea;border-radius:10px;box-shadow:0 8px 24px rgba(30,40,80,.16);padding:4px;display:none}#qqs-dock-panel .dk-at-item{display:flex;align-items:center;gap:6px;padding:5px 9px;border-radius:7px;cursor:pointer;font-size:12px;color:#1f2329;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#qqs-dock-panel .dk-at-item.on,#qqs-dock-panel .dk-at-item:hover{background:#eef3ff;color:#2b5fd0}#qqs-dock-panel .dk-at-empty{color:#aaa;font-size:12px;padding:6px 9px}#qqs-lightbox .lb-x{position:fixed;right:16px;top:10px;color:#fff;font-size:30px;cursor:pointer;line-height:1;padding:6px}#qqs-dock-panel.dk-full{left:0!important;top:0!important;right:0!important;bottom:0!important;width:100vw!important;max-width:100vw!important;height:100vh!important;max-height:100vh!important;border-radius:0;z-index:2147482000;display:flex;flex-direction:column}#qqs-dock-panel.dk-full .dk-h,#qqs-dock-panel.dk-full .dk-detect{flex:none}#qqs-dock-panel.dk-full .dk-b{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;padding:8px 14px 6px}#qqs-dock-panel.dk-full .dk-chat-wrap{display:flex;flex-direction:column;flex:1;min-height:0}#qqs-dock-panel.dk-full .dk-chat-head{flex:none}#qqs-dock-panel.dk-full .dk-chat-box{flex:1;height:auto!important;min-height:0!important;max-height:none!important;overflow-y:auto;overscroll-behavior:contain}#qqs-dock-panel.dk-full .dk-composer{flex:none;margin-top:6px}#qqs-dock-panel .dk-tab{overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:none}#qqs-dock-panel .dk-tab::-webkit-scrollbar{display:none}#qqs-dock-panel .dk-tab button{white-space:nowrap;flex:0 0 auto}@media (max-width:560px){#qqs-dock-panel:not(.dk-full){left:8px!important;right:8px!important;width:auto!important;max-width:calc(100vw - 16px)!important;bottom:96px!important}#qqs-dock-panel .dk-h{font-size:13px;padding:8px 10px;gap:6px}#qqs-dock-panel .dk-b{padding:8px 10px;font-size:13px}#qqs-dock-panel .dk-tab button{font-size:12px;padding:7px 10px}#qqs-dock-panel .dk-btn{font-size:12px;padding:6px 10px}#qqs-dock-panel .dk-row{gap:6px;margin:5px 0}#qqs-dock-panel select.qqs-sel,#qqs-dock-panel input.qqs-txt,#qqs-dock-panel textarea.qqs-txt{padding:7px 9px;font-size:16px}#qqs-dock-panel .dk-list{max-height:46vh}#qqs-dock-panel .dk-item{padding:8px 6px;gap:6px}}@media (max-width:560px){#qqs-dock-panel.dk-full{border-radius:0}#qqs-dock-panel.dk-full .dk-b{padding-bottom:calc(8px + env(safe-area-inset-bottom,0px))}#qqs-dock-panel.dk-full .dk-tab{position:sticky;top:0;background:#fff;z-index:2}}@media (max-width:560px){#qqs-dock-ball{width:46px;height:46px;line-height:46px;font-size:21px}}"
1721
+ var DOCK_CSS2 = "#qqs-dock-panel .dk-chat-head{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:0 0 6px}#qqs-dock-panel .dk-chat-box{overflow-y:auto;overscroll-behavior:contain;background:#f5f6f8;border:1px solid #e6e8ec;border-radius:10px;padding:10px 12px;box-sizing:border-box;height:min(36vh,300px);min-height:140px;scroll-behavior:auto}#qqs-dock-panel .dk-chat-box::-webkit-scrollbar{width:6px}#qqs-dock-panel .dk-chat-box::-webkit-scrollbar-thumb{background:#d3d7dd;border-radius:3px}#qqs-dock-panel .dk-crow{display:flex;gap:8px;align-items:flex-start;margin:0 0 12px}#qqs-dock-panel .dk-crow.out{flex-direction:row-reverse}#qqs-dock-panel .dk-ava{width:32px;height:32px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;color:#fff;overflow:hidden;user-select:none;background:linear-gradient(150deg,#8fb3e8,#5f8fd9)}#qqs-dock-panel .dk-crow.out .dk-ava{background:linear-gradient(150deg,#5ec7f2,#3b8fe0)}#qqs-dock-panel .dk-cmain{display:flex;flex-direction:column;max-width:calc(100% - 40px);min-width:0}#qqs-dock-panel .dk-crow.in .dk-cmain{align-items:flex-start}#qqs-dock-panel .dk-crow.out .dk-cmain{align-items:flex-end}#qqs-dock-panel .dk-cmeta{font-size:11px;color:#9aa0a8;margin:0 6px 2px;max-width:100%;display:flex;align-items:center;gap:5px;flex-wrap:wrap}#qqs-dock-panel .dk-crow.out .dk-cmeta{flex-direction:row-reverse}#qqs-dock-panel .dk-cbubble{padding:7px 11px;font-size:13px;line-height:1.55;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;box-shadow:0 1px 2px rgba(20,30,60,.06);max-width:100%}#qqs-dock-panel .dk-cbubble a.dk-link{color:#1a66d6;text-decoration:underline;word-break:break-all;cursor:pointer}#qqs-dock-panel .dk-crow.out .dk-cbubble a.dk-link{color:#eaf3ff}#qqs-dock-panel .dk-crow.in .dk-cbubble{background:#fff;border:1px solid #e3e6ea;color:#1f2329;border-radius:3px 10px 10px 10px}#qqs-dock-panel .dk-crow.out .dk-cbubble{background:linear-gradient(180deg,#69a6ff,#3d7df5);color:#fff;border-radius:10px 3px 10px 10px}#qqs-dock-panel .dk-img{display:block;max-width:min(230px,52vw);max-height:200px;border-radius:6px;margin:0 0 3px;object-fit:cover;cursor:zoom-in}#qqs-dock-panel .dk-audio{display:block;max-width:min(260px,60vw);width:100%;height:34px;margin:0 0 2px}#qqs-dock-panel .dk-file{display:inline-flex;align-items:center;gap:5px;max-width:100%;padding:6px 12px;border-radius:8px;background:#f0f6ff;border:1px solid #cfe0fa;color:#2b6bd8;font-size:13px;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#qqs-dock-panel .dk-file:hover{background:#e2edff}#qqs-dock-panel .dk-video{display:block;max-width:min(260px,60vw);max-height:180px;border-radius:6px;margin:0 0 2px}#qqs-dock-panel .dk-ctag{display:inline-block;font-size:10px;color:#5b8ff0;background:#eaf2ff;border:1px solid #d4e3fd;border-radius:8px;padding:0 6px}#qqs-dock-panel .dk-chat-top{text-align:center;color:#b0b4bb;font-size:11px;padding:2px 0 6px;user-select:none}#qqs-dock-panel .dk-chat-bottom{text-align:center;color:#c3c7cd;font-size:11px;padding:6px 0 0}#qqs-dock-panel .dk-composer{margin-top:8px;border:1px solid #e3e6ea;border-radius:10px;background:#fff;overflow:visible}#qqs-dock-panel .dk-composer textarea{width:100%;box-sizing:border-box;border:none;outline:none;resize:none;font:inherit;font-size:13px;color:#1f2329;background:transparent;padding:8px 10px 4px;line-height:1.5;max-height:120px}#qqs-dock-panel .dk-cbar{display:flex;align-items:center;gap:4px;padding:4px 8px 6px;flex-wrap:wrap}#qqs-dock-panel .dk-cbar .dk-btn{padding:3px 10px;font-size:12px;border-radius:7px}#qqs-dock-panel .dk-cbar .dk-send{background:linear-gradient(180deg,#69a6ff,#3d7df5);color:#fff;border:none;border-radius:8px;padding:5px 18px;font-size:13px;font-weight:600;cursor:pointer}#qqs-dock-panel .dk-cbar .dk-send:disabled{opacity:.5;cursor:default}#qqs-lightbox{position:fixed;inset:0;z-index:2147483000;background:rgba(8,10,18,.82);display:flex;align-items:center;justify-content:center;cursor:zoom-out}#qqs-lightbox img{max-width:92vw;max-height:92vh;border-radius:8px;box-shadow:0 10px 60px rgba(0,0,0,.6)}#qqs-dock-panel .dk-composer{position:relative}#qqs-dock-panel .dk-at-pop{position:absolute;left:6px;bottom:calc(100% - 4px);z-index:30;min-width:200px;max-width:90%;max-height:190px;overflow-y:auto;background:#fff;border:1px solid #e0e4ea;border-radius:10px;box-shadow:0 8px 24px rgba(30,40,80,.16);padding:4px;display:none}#qqs-dock-panel .dk-at-item{display:flex;align-items:center;gap:6px;padding:5px 9px;border-radius:7px;cursor:pointer;font-size:12px;color:#1f2329;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#qqs-dock-panel .dk-at-item.on,#qqs-dock-panel .dk-at-item:hover{background:#eef3ff;color:#2b5fd0}#qqs-dock-panel .dk-at-empty{color:#aaa;font-size:12px;padding:6px 9px}#qqs-lightbox .lb-x{position:fixed;right:16px;top:10px;color:#fff;font-size:30px;cursor:pointer;line-height:1;padding:6px}#qqs-dock-panel.dk-full{left:0!important;top:0!important;right:0!important;bottom:0!important;width:100vw!important;max-width:100vw!important;height:100vh!important;max-height:100vh!important;border-radius:0;z-index:2147482000;display:flex;flex-direction:column}#qqs-dock-panel.dk-full .dk-h,#qqs-dock-panel.dk-full .dk-detect{flex:none}#qqs-dock-panel.dk-full .dk-b{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;padding:8px 14px 6px}#qqs-dock-panel.dk-full .dk-chat-wrap{display:flex;flex-direction:column;flex:1;min-height:0}#qqs-dock-panel.dk-full .dk-chat-head{flex:none}#qqs-dock-panel.dk-full .dk-chat-box{flex:1;height:auto!important;min-height:0!important;max-height:none!important;overflow-y:auto;overscroll-behavior:contain}#qqs-dock-panel.dk-full .dk-composer{flex:none;margin-top:6px}#qqs-dock-panel .dk-tab{overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:none}#qqs-dock-panel .dk-tab::-webkit-scrollbar{display:none}#qqs-dock-panel .dk-tab button{white-space:nowrap;flex:0 0 auto}@media (max-width:560px){#qqs-dock-panel:not(.dk-full){left:8px!important;right:8px!important;width:auto!important;max-width:calc(100vw - 16px)!important;bottom:96px!important}#qqs-dock-panel .dk-h{font-size:13px;padding:8px 10px;gap:6px}#qqs-dock-panel .dk-b{padding:8px 10px;font-size:13px}#qqs-dock-panel .dk-tab button{font-size:12px;padding:7px 10px}#qqs-dock-panel .dk-btn{font-size:12px;padding:6px 10px}#qqs-dock-panel .dk-row{gap:6px;margin:5px 0}#qqs-dock-panel select.qqs-sel,#qqs-dock-panel input.qqs-txt,#qqs-dock-panel textarea.qqs-txt{padding:7px 9px;font-size:16px}#qqs-dock-panel .dk-list{max-height:46vh}#qqs-dock-panel .dk-item{padding:8px 6px;gap:6px}}@media (max-width:560px){#qqs-dock-panel.dk-full{border-radius:0}#qqs-dock-panel.dk-full .dk-b{padding-bottom:calc(8px + env(safe-area-inset-bottom,0px))}#qqs-dock-panel.dk-full .dk-tab{position:sticky;top:0;background:#fff;z-index:2}}@media (max-width:560px){#qqs-dock-ball{width:46px;height:46px;line-height:46px;font-size:21px}}#qqs-dock-panel .dk-chat-wrap{position:relative}#qqs-dock-panel .dk-jump-latest{display:none;position:absolute;right:12px;bottom:76px;z-index:6;align-items:center;gap:4px;padding:5px 13px;font-size:12px;font-weight:600;color:#fff;cursor:pointer;border:none;border-radius:15px;background:linear-gradient(180deg,#69a6ff,#3d7df5);box-shadow:0 3px 12px rgba(30,60,140,.32);transition:transform .12s,box-shadow .12s}#qqs-dock-panel .dk-jump-latest.on{display:inline-flex}#qqs-dock-panel .dk-jump-latest:hover{background:linear-gradient(180deg,#5c9bfa,#2f6fe6);box-shadow:0 4px 16px rgba(30,60,140,.4)}#qqs-dock-panel .dk-jump-latest:active{transform:scale(.95)}"
1722
1722
  function ensureDockCss2() { try { if (!document.getElementById('qqs-dock-css2')) { var st = document.createElement('style'); st.id = 'qqs-dock-css2'; st.textContent = DOCK_CSS2; document.head.appendChild(st) } } catch (e) {} };
1723
1723
  function ensureDockCss() { try { if (!document.getElementById('qqs-dock-css')) { var st = document.createElement('style'); st.id = 'qqs-dock-css'; st.textContent = DOCK_CSS; document.head.appendChild(st) } } catch (e) {} }
1724
1724
  function startQqDock(sessionsSvc) {
@@ -1887,6 +1887,18 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
1887
1887
  document.body.appendChild(panel)
1888
1888
  }
1889
1889
  open = true
1890
+ // 2026-10-04 五次修复(主人实测:"刷新后打开 dock 还是从 9/21 开始"):
1891
+ // 关闭面板时 DOM 会清空(closePanel 里 innerHTML=''),但 **JS 里的 state.chatItems 还留着** ——
1892
+ // 重开时 paintBody 见 chatItems 非空就**不重新加载**(L3965 的条件),直接复用那批旧数据
1893
+ // (含"上次翻到哪",于是又停在最旧处)。
1894
+ // 这里每次打开都**丢弃聊天缓存**,强制走 loadChat(true) 拉最新一页 ⇒ 配合贴底逻辑,
1895
+ // 打开即停在最新(= 主人说的老行为)。
1896
+ try {
1897
+ state.chatItems = []
1898
+ state.chatOldest = 0
1899
+ state.chatNewest = 0
1900
+ state.chatErr = ''
1901
+ } catch (e) { /* ignore */ }
1890
1902
  // 先量球位置(隐藏前), 再隐藏球
1891
1903
  var wr = wrap.getBoundingClientRect()
1892
1904
  lastBallRect = { left: wr.left, top: wr.top, right: wr.right, bottom: wr.bottom, width: wr.width, height: wr.height }
@@ -1932,7 +1944,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
1932
1944
  setInterval(refreshBadge, 20000)
1933
1945
 
1934
1946
  // ── 面板状态(每个实例独立保存, 切回不丢) ──
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 }
1947
+ 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, bpExt: null, bpExtBusy: '', bpReq: '', bpReqHint: '', bpSrcText: null, bpSrcPath: '', 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
1948
  // 🗂 自定义目标分组(仿 QQ 分组): 2026-09-12 起**host 持久化**({dataRoot}/.qqbot/target-groups.json),
1937
1949
  // localStorage 只当秒开缓存 —— 这样 **AI 与主人共用同一份分组**(agent 侧 broadcast_send 可直接写分组名群发),
1938
1950
  // 顺带修掉"换个浏览器分组就没了"的老毛病。
@@ -3553,6 +3565,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3553
3565
  + '<span class="dk-msg" id="dk-chat-status" style="flex:1">QQ 会话记录(注入/系统文本已滤)</span>'
3554
3566
  + '</div>'
3555
3567
  + '<div class="dk-chat-box" id="dk-chat-box"></div>'
3568
+ + '<button class="dk-jump-latest" id="dk-jump-latest" title="跳到最新消息">↓ 最新</button>'
3556
3569
  + '</div>'
3557
3570
  // QQ 风格输入栏: 文本发送; bbcode [MEDIA:图片|路径/链接] 支持本地图与网络图
3558
3571
  body += '<div class="dk-composer">'
@@ -3881,8 +3894,14 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3881
3894
  var btnN = (ev.buttons || []).length
3882
3895
  var rowc = bpRowCount(ev)
3883
3896
  var overL = rowc.rows > 5
3897
+ // 🤖 自定义事件(带 file): 编辑器不适用 → 列表上标出来, 并显示模块文件与加载状态
3898
+ var isExt = !!(ev.file && String(ev.file).trim())
3899
+ var extSt = isExt ? bpExtOf(ev.file) : null
3900
+ var extBad = extSt && extSt.error
3884
3901
  body += '<div class="dk-item" style="cursor:pointer;background:' + (sel ? '#f1ecff' : 'transparent') + '" data-bpidx="' + i + '">'
3885
- + '<span style="flex:1"><b>' + esc(ev.name || '(未命名)') + '</b> <span style="color:#888;font-size:11px">/ ' + esc(ev.id || '?') + ' · ' + btnN + ' 按钮·每行' + rowc.per + ' → ' + rowc.rows + '行' + (overL ? ' <b style="color:#e03131">⚠超限</b>' : '') + ' · ' + (ev.perm && ev.perm.type ? esc(ev.perm.type) : 'all') + ' · ' + (ev.expireSec || 600) + 's</span></span>'
3902
+ + '<span style="flex:1"><b>' + (isExt ? '🤖 ' : '') + esc(ev.name || '(未命名)') + '</b> <span style="color:#888;font-size:11px">/ ' + esc(ev.id || '?') + ' · ' + (isExt
3903
+ ? ('AI 模块 ' + esc(String(ev.file)) + (extSt ? (extSt.exists === false ? ' <b style="color:#e03131">文件缺失</b>' : (extBad ? ' <b style="color:#e03131">加载失败</b>' : ' <b style="color:#2f9e44">OK</b>')) : ''))
3904
+ : (btnN + ' 按钮·每行' + rowc.per + ' → ' + rowc.rows + '行' + (overL ? ' <b style="color:#e03131">⚠超限</b>' : ''))) + ' · ' + (ev.perm && ev.perm.type ? esc(ev.perm.type) : 'all') + ' · ' + (ev.expireSec || 600) + 's</span></span>'
3886
3905
  + '<button class="dk-btn" data-bp2card="' + i + '" title="把此事件的卡片预设(正文+按钮)复制进「📝 卡片」编辑器">📋</button>'
3887
3906
  + '<button class="dk-btn no" data-bpdel="' + i + '">🗑</button></div>'
3888
3907
  })
@@ -3890,10 +3909,65 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3890
3909
  body += '<div class="dk-row"><button class="dk-btn ok" id="dk-bp-new">➕ 新建事件</button>'
3891
3910
  body += '<button class="dk-btn ok" id="dk-bp-save">💾 保存全部(live 热更)</button>'
3892
3911
  body += '<span class="dk-msg" id="dk-bp-hint" style="color:#2f9e44"></span></div>'
3912
+ // 🤖🤖 让 AI 写(2026-10-05 主人要求): 描述需求 → 拼成一条消息唤醒当前会话的 AI,
3913
+ // 它按契约把模块写进 {dataRoot}/.qqbot-extensions/botplay/ 并登记事件。
3914
+ // 走面板既有的"用户代你发送/唤醒 AI"通道(与「让ai写样例库」同一条路, 不新开渠道)。
3915
+ body += '<div style="border-top:1px dashed #e2d9ff;margin-top:8px;padding-top:8px">'
3916
+ body += '<div class="dk-row" style="font-weight:700;font-size:13px;margin:2px 0 2px;color:#4a3a9f">🤖 让 AI 写自定义事件</div>'
3917
+ body += '<div class="dk-row"><span class="dk-msg" style="flex:1;font-size:11px;color:#777">直接用大白话描述你想要的互动事件(谁点、点了干什么、谁能点、要不要统计…),'
3918
+ + '点下面按钮就会把你的需求 + QQ 官方接口文档 + 自定义事件契约一起发给<b>当前会话的 AI</b>,由它写成一个模块并登记成 botplay 事件。</span></div>'
3919
+ body += '<div class="dk-row" style="align-items:flex-start"><textarea id="dk-bp-req" placeholder="例:我要一个签到人数统计。按钮1 签到,所有人都能点,每人只能点一次;按钮2 结束,只有我能点。我点结束后 bot 在群里发消息,列出签到人数和所有签到者的名字。" style="flex:1;min-height:72px;font-size:12px;font-family:inherit;box-sizing:border-box;padding:6px;border:1px solid #ddd;border-radius:6px;resize:vertical">' + esc(state.bpReq || '') + '</textarea></div>'
3920
+ body += '<div class="dk-row"><button class="dk-btn ok" id="dk-bp-aiwrite">🤖 让 AI 写</button>'
3921
+ + '<span class="dk-msg" style="flex:1;font-size:11px;color:#888">'
3922
+ + (state.detectedHit && state.detectedHit.peerId
3923
+ ? ('目标: ' + (state.detectedHit.scope === 'group' ? '群' : '私聊') + '「' + esc(state.detectedHit.name || state.detectedHit.peerId.slice(-6)) + '」(顶栏「当前会话」)')
3924
+ : '⚠ 请先把顶栏「当前会话」切到与 bot 的群/私聊 —— 不然不知道把需求发给谁')
3925
+ + '</span></div>'
3926
+ body += '<div class="dk-msg" id="dk-bp-aihint" style="color:#2f9e44;margin:2px 0 2px">' + esc(state.bpReqHint || '') + '</div>'
3927
+ body += '</div>'
3893
3928
  // 编辑器
3894
3929
  if (state.bpDraft) {
3895
3930
  var D = state.bpDraft
3931
+ var DIsExt = !!(D.file && String(D.file).trim())
3896
3932
  body += '<div style="border-top:1px dashed #e2d9ff;margin-top:8px;padding-top:8px">'
3933
+ if (DIsExt) {
3934
+ // ── 自定义事件: **不显示**下面的编辑表单(那些字段对模块驱动的卡片没有意义), 换成模块提示区 ──
3935
+ var st = bpExtOf(D.file)
3936
+ var mt = st && st.exists && st.mtime ? new Date(st.mtime) : null
3937
+ var mtStr = mt ? (mt.getFullYear() + '-' + String(mt.getMonth() + 1).padStart(2, '0') + '-' + String(mt.getDate()).padStart(2, '0') + ' ' + String(mt.getHours()).padStart(2, '0') + ':' + String(mt.getMinutes()).padStart(2, '0') + ':' + String(mt.getSeconds()).padStart(2, '0')) : ''
3938
+ body += '<div style="font-weight:700;font-size:12px;margin:2px 0 4px;color:#4a3a9f">🤖 自定义事件(由 AI 写的模块驱动)</div>'
3939
+ body += '<div class="dk-row"><span class="dk-msg" style="flex:1;font-size:12px;color:#555">此事件由 AI 编写的 JS 模块驱动,编辑器不适用。'
3940
+ + '模块文件位于数据根下的 .qqbot-extensions 目录(插件升级/重装不会覆盖)。</span></div>'
3941
+ body += '<div class="dk-row"><span class="dk-msg" style="flex:1;font-size:11px;color:#333;word-break:break-all">'
3942
+ + '<b>事件名</b> ' + esc(D.name || '(未命名)') + ' <b>id</b> ' + esc(D.id || '?') + '<br>'
3943
+ + '<b>模块文件</b> <code>' + esc(pickPath(st && st.path, state) || '(路径待取)') + '</code><br>'
3944
+ + '<b>状态</b> ' + (st
3945
+ ? ((st.exists === false ? '<b style="color:#e03131">文件不存在</b>' : '<b style="color:#2f9e44">文件存在</b>')
3946
+ + (mtStr ? ' 最后修改 ' + mtStr : '')
3947
+ + (st.size ? ' ' + st.size + ' 字节' : '')
3948
+ + (st.hooks && st.hooks.length ? ' 导出: ' + esc(st.hooks.join(',')) : '')
3949
+ + (st.loadedMtime && st.mtime && st.loadedMtime === st.mtime ? ' <b style="color:#2f9e44">已加载</b>' : (st.loadedMtime ? ' <b style="color:#e8590c">已改动, 需重载</b>' : ' <b style="color:#868e96">尚未加载</b>')))
3950
+ : '(插件未就绪或正在读取…)')
3951
+ + '</span></div>'
3952
+ // 加载失败 / 目录不存在 / 插件未就绪 的错误摘要(红色)
3953
+ if (st && st.error) body += '<div class="dk-msg" style="color:#e03131;font-size:11px;word-break:break-all">✗ 模块加载失败: ' + esc(st.error) + '</div>'
3954
+ if (state.bpExt && state.bpExt.dirExists === false && state.bpExt.dir) body += '<div class="dk-msg" style="color:#e8590c;font-size:11px">⚠ 扩展目录还不存在: ' + esc(state.bpExt.dir) + '(让 AI 写一个模块时它会自动创建)</div>'
3955
+ if (state.bpExt && state.bpExt.note) body += '<div class="dk-msg" style="color:#e8590c;font-size:11px">⚠ ' + esc(state.bpExt.note) + '</div>'
3956
+ body += '<div class="dk-row" style="gap:8px">'
3957
+ + '<button class="dk-btn ok" id="dk-bp-extreload"' + (state.bpExtBusy ? ' disabled' : '') + ' title="热重载模块: 清掉插件内存里的缓存, 下次发卡/点击就用新代码(不用重启宿主)">🔄 重载模块</button>'
3958
+ + '<button class="dk-btn" id="dk-bp-extsrc" title="读取该模块源码(在下面文本域里显示, 可全选复制)">📄 查看源码</button>'
3959
+ + '<button class="dk-btn" id="dk-bp-extdir" title="把扩展目录路径复制到剪贴板(方便用编辑器/VSCode 打开)">📁 复制路径</button>'
3960
+ + '</div>'
3961
+ body += '<div class="dk-msg" id="dk-bp-exthint" style="color:#2f9e44;font-size:11px;margin:2px 0">' + esc(state.bpExtBusy || '') + '</div>'
3962
+ if (state.bpSrcText !== undefined && state.bpSrcText !== null) {
3963
+ body += '<div class="dk-row" style="align-items:flex-start"><textarea id="dk-bp-src" readonly style="flex:1;min-height:150px;font-size:11px;font-family:monospace;box-sizing:border-box;padding:6px;border:1px solid #ddd;border-radius:6px;resize:vertical">' + esc(state.bpSrcText) + '</textarea></div>'
3964
+ body += '<div class="dk-row"><span class="dk-msg" style="flex:1;font-size:11px;color:#888">' + esc(state.bpSrcPath || '') + ' (只读; 想改就让 AI 改, 或自己用编辑器打开)</span><button class="dk-btn" id="dk-bp-srcclose">收起</button></div>'
3965
+ }
3966
+ body += '<div class="dk-row"><span class="dk-msg" style="flex:1;font-size:11px;color:#777">想让这个事件回到普通编辑器?把事件 JSON 里的 <code>file</code> 字段删掉即可(或让 AI 改)。</span></div>'
3967
+ body += '<div class="dk-row"><button class="dk-btn" id="dk-bp-extevents" title="打开 botplay-events.json 所在目录路径(复制到剪贴板)">📋 复制事件文件路径</button>'
3968
+ + '<span class="dk-msg" style="flex:1;font-size:11px;color:#888">事件登记: botplay-events.json 里的 <code>' + esc(D.id || '?') + '</code> → file <code>' + esc(String(D.file)) + '</code></span></div>'
3969
+ body += '</div>'
3970
+ } else {
3897
3971
  body += '<div class="dk-row"><label style="font-size:12px;color:#555;width:56px">事件名</label><input class="qqs-txt" id="dk-bp-name" value="' + esc(D.name) + '" style="flex:1" placeholder="如: 签到"></div>'
3898
3972
  body += '<div class="dk-row"><label style="font-size:12px;color:#555;width:56px">id</label><input class="qqs-txt" id="dk-bp-id" value="' + esc(D.id) + '" style="flex:1" placeholder="英文/数字/_- (触发用)"></div>'
3899
3973
  body += '<div class="dk-row" style="align-items:flex-start"><label style="font-size:12px;color:#555;width:56px;padding-top:4px">卡片正文</label>'
@@ -3928,6 +4002,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
3928
4002
  body += '<div class="dk-row"><button class="dk-btn" id="dk-bp-addbtn">➕ 加按钮</button>'
3929
4003
  + '<span class="dk-msg" id="dk-bp-addmsg">每行 ' + rowc2.per + ' 个 × 最多 5 行 = 上限 ' + (rowc2.per * 5) + ' 个(QQ 键盘: ≤5行 × 每行≤5)</span></div>'
3930
4004
  body += '</div>'
4005
+ }
3931
4006
  }
3932
4007
  }
3933
4008
  body += status
@@ -4202,10 +4277,31 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
4202
4277
  var crf = panel.querySelector('#dk-chat-refresh')
4203
4278
  if (crf) crf.onclick = function () { state.chatItems = []; state.chatErr = ''; state.chatBusy = ''; loadChat(true) }
4204
4279
  var cbox = panel.querySelector('#dk-chat-box')
4280
+ // 「↓ 最新」跳转按钮(2026-10-04 主人要求):往上翻历史后一键回到最新。
4281
+ // 显示条件 = 距底部超过 40px;点一下滚到底、并顺手拉一次更新的消息。
4282
+ var jumpLatest = function () {
4283
+ var b = panel.querySelector('#dk-chat-box')
4284
+ var jb = panel.querySelector('#dk-jump-latest')
4285
+ if (b) { b.scrollTop = b.scrollHeight }
4286
+ if (jb) jb.classList.remove('on')
4287
+ if (state.tab === 'chat' && !state.chatBusy) { try { loadChatNewer() } catch (e) { /* 还没加载过也没关系 */ } }
4288
+ }
4289
+ var syncJumpLatest = function (b) {
4290
+ try {
4291
+ var jb = panel.querySelector('#dk-jump-latest')
4292
+ if (!jb || !b) return
4293
+ if ((b.scrollHeight - b.scrollTop - b.clientHeight) > 40) jb.classList.add('on')
4294
+ else jb.classList.remove('on')
4295
+ } catch (e) { /* ignore */ }
4296
+ }
4205
4297
  if (cbox) cbox.onscroll = function () {
4298
+ // ⚠️ 必须放在下面的 early return **之前** —— 否则 tab 不是 chat / 加载中就永远不显示按钮
4299
+ syncJumpLatest(cbox)
4206
4300
  if (state.tab !== 'chat' || !state.chatMore || state.chatBusy) return
4207
4301
  if (cbox.scrollTop <= 4) loadChat(false) // 顶部 → 加载更早
4208
4302
  }
4303
+ var jbtn = panel.querySelector('#dk-jump-latest')
4304
+ if (jbtn) jbtn.onclick = jumpLatest
4209
4305
  // 2026-10-01 主人要求:「滚轮向下滚一次就刷新一下」——**不管有没有滚动成功**
4210
4306
  // (已经停在底部也刷:方便随时确认有没有新消息,比每个来回点 🔄 刷新省事)。
4211
4307
  // 向上滚不拦(交给上面的"到顶部加载更早",两个功能不打架)。
@@ -4434,6 +4530,99 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
4434
4530
  }
4435
4531
  var bpSave = panel.querySelector('#dk-bp-save')
4436
4532
  if (bpSave) bpSave.onclick = saveBotplay
4533
+
4534
+ // ── 🤖 让 AI 写(2026-10-05): 需求文本域 + 按钮 ──
4535
+ var bpReq = panel.querySelector('#dk-bp-req')
4536
+ if (bpReq) bpReq.oninput = function (e) { state.bpReq = e.target.value }
4537
+ var bpAi = panel.querySelector('#dk-bp-aiwrite')
4538
+ if (bpAi) bpAi.onclick = letAiWriteBotplay
4539
+ // ── 🤖 自定义事件: 重载模块 / 看源码 / 复制路径 ──
4540
+ var bpReload = panel.querySelector('#dk-bp-extreload')
4541
+ if (bpReload) bpReload.onclick = function () {
4542
+ var f = state.bpDraft && state.bpDraft.file ? String(state.bpDraft.file) : ''
4543
+ var eh = panel.querySelector('#dk-bp-exthint')
4544
+ state.bpExtBusy = '重载中…'
4545
+ if (eh) eh.textContent = '重载中…'
4546
+ apiPost('group/botplay-ext/reload', { ns: state.ns || 'im-qqbot', file: f }).then(function (d) {
4547
+ state.bpExtBusy = ''
4548
+ if (eh) eh.textContent = (d && d.ok) ? ('✓ ' + (d.msg || '已重载')) : ('✗ ' + ((d && (d.error || d.msg)) || '重载失败'))
4549
+ loadBotplayExt(true)
4550
+ }).catch(function (e) {
4551
+ state.bpExtBusy = ''
4552
+ if (eh) eh.textContent = '重载异常: ' + e.message
4553
+ })
4554
+ }
4555
+ var bpSrc = panel.querySelector('#dk-bp-extsrc')
4556
+ if (bpSrc) bpSrc.onclick = function () {
4557
+ var f = state.bpDraft && state.bpDraft.file ? String(state.bpDraft.file) : ''
4558
+ var eh = panel.querySelector('#dk-bp-exthint')
4559
+ if (!f) return
4560
+ if (eh) eh.textContent = '读取源码中…'
4561
+ api('group/botplay-ext/source', 'ns=' + encodeURIComponent(state.ns || 'im-qqbot') + '&file=' + encodeURIComponent(f)).then(function (d) {
4562
+ if (d && d.ok) { state.bpSrcText = d.text; state.bpSrcPath = d.path; paintBody() }
4563
+ else if (eh) eh.textContent = '✗ ' + ((d && (d.error || d.msg)) || '读取失败')
4564
+ }).catch(function (e) { if (eh) eh.textContent = '异常: ' + e.message })
4565
+ }
4566
+ var bpSrcClose = panel.querySelector('#dk-bp-srcclose')
4567
+ if (bpSrcClose) bpSrcClose.onclick = function () { state.bpSrcText = null; state.bpSrcPath = ''; paintBody() }
4568
+ var bpExtDir = panel.querySelector('#dk-bp-extdir')
4569
+ if (bpExtDir) bpExtDir.onclick = function () {
4570
+ var p = (state.bpExt && state.bpExt.dir) || ''
4571
+ if (!p) return
4572
+ copyText(p, '#dk-bp-exthint', '✓ 已复制扩展目录: ')
4573
+ }
4574
+ var bpExtEvents = panel.querySelector('#dk-bp-extevents')
4575
+ if (bpExtEvents) bpExtEvents.onclick = function () {
4576
+ var dr = (state.bpExt && state.bpExt.dataRoot) || ''
4577
+ if (!dr) return
4578
+ copyText(dr + '\\botplay-events.json', '#dk-bp-exthint', '✓ 已复制事件文件路径: ')
4579
+ }
4580
+ }
4581
+ // 复制文本到剪贴板(带老浏览器回落); hintSel 指定的元素里回显提示
4582
+ function copyText(text, hintSel, okPrefix) {
4583
+ var h = hintSel ? panel.querySelector(hintSel) : null
4584
+ var done = function () { if (h) h.textContent = (okPrefix || '已复制: ') + text }
4585
+ try {
4586
+ if (navigator.clipboard && navigator.clipboard.writeText) {
4587
+ navigator.clipboard.writeText(text).then(done, function () { done() })
4588
+ return
4589
+ }
4590
+ } catch (e) { /* 回落 */ }
4591
+ done()
4592
+ }
4593
+ // 在 state.bpExt.mods 里按文件名找模块状态(插件未就绪时 bpExt 可能是 null)
4594
+ function bpExtOf(file) {
4595
+ if (!state.bpExt || !Array.isArray(state.bpExt.mods)) return null
4596
+ var f = String(file || '')
4597
+ for (var i = 0; i < state.bpExt.mods.length; i++) { if (state.bpExt.mods[i] && state.bpExt.mods[i].file === f) return state.bpExt.mods[i] }
4598
+ return null
4599
+ }
4600
+ // 路径优先用 host 报的绝对路径; host 没给就按数据根拼一个(至少让主人知道该去哪儿找)
4601
+ function pickPath(p, st) {
4602
+ if (p) return p
4603
+ var dr = (st && st.dataRoot) || (state.bpExt && state.bpExt.dataRoot) || ''
4604
+ return dr ? (dr + '\\.qqbot-extensions\\botplay\\' + String((state.bpDraft && state.bpDraft.file) || '')) : ''
4605
+ }
4606
+ // 🤖 让 AI 写自定义事件: 把需求文本域的内容交给 host 现拼提示词(附接口文档+契约), 再由它唤醒当前会话 AI
4607
+ function letAiWriteBotplay() {
4608
+ var h = panel.querySelector('#dk-bp-aihint')
4609
+ var reqEl = panel.querySelector('#dk-bp-req')
4610
+ var req = String((reqEl ? reqEl.value : state.bpReq) || '').trim()
4611
+ state.bpReq = req
4612
+ if (!req) { if (h) h.textContent = '✗ 先在输入框里写你要的事件需求(大白话就行)'; return }
4613
+ var hit = state.detectedHit
4614
+ if (!hit || !hit.peerId) { if (h) h.textContent = '✗ 顶栏「当前会话」不是 QQ 群/私聊 —— 先切到与 bot 的群或私聊会话, 才知道把需求发给谁'; return }
4615
+ var ns = hit.ns || state.ns
4616
+ // 顺手把"已占用的 id/文件名"一起带过去, 免得 AI 撞号(它自己也能读文件, 但这句更省事)
4617
+ var ids = (state.bpEvents || []).map(function (e) { return e.id }).filter(Boolean).join(', ')
4618
+ var files = (state.bpEvents || []).map(function (e) { return e.file }).filter(Boolean).join(', ')
4619
+ if (ids || files) req += '\n\n(小提示: 已经占用的事件 id: ' + (ids || '无') + ';已用模块文件: ' + (files || '无') + ' —— 请避开重名)'
4620
+ if (h) h.textContent = '发给 AI 中…(' + (hit.scope === 'group' ? '群' : '私聊') + '「' + (hit.name || '') + '」)'
4621
+ apiPost('group/botplay-ext/let-ai-write', { ns: ns || 'im-qqbot', scope: hit.scope, peerId: hit.peerId, requirement: req })
4622
+ .then(function (d) {
4623
+ if (h) h.textContent = (d && d.ok) ? ('✓ ' + d.msg + ' 写完记得点事件的「🔄 重载模块」') : ('✗ ' + ((d && (d.error || d.msg)) || '失败'))
4624
+ })
4625
+ .catch(function (e) { if (h) h.textContent = '异常: ' + e.message })
4437
4626
  }
4438
4627
  // QQ 键盘布局限制(官方 API): 键盘最多 5 行 × 每行最多 5 个按钮 → 行数=ceil(按钮数/每行数)≤5 才合法
4439
4628
  function bpRowCount(ev) {
@@ -4463,6 +4652,19 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
4463
4652
  paintBody()
4464
4653
  }
4465
4654
  }).catch(function () {})
4655
+ // 🤖 自定义事件模块状态(路径/是否存在/mtime/加载错误) —— 与事件列表并行拉, 不阻塞编辑器渲染
4656
+ loadBotplayExt(false)
4657
+ }
4658
+ // 🤖 自定义事件: 拉模块状态(有自定义事件才拉, 省一次请求)
4659
+ // repaint=true 时重绘(重载后要刷新"已加载/已改动"标记); 否则只更新 state, 不打断正在打字的主人
4660
+ function loadBotplayExt(repaint) {
4661
+ if (state.tab !== 'bp') return
4662
+ var hasExt = (state.bpEvents || []).some(function (e) { return !!(e && e.file && String(e.file).trim()) })
4663
+ if (!hasExt && !repaint) return
4664
+ api('group/botplay-ext/status', state.ns ? 'ns=' + encodeURIComponent(state.ns) : '')
4665
+ .then(function (d) {
4666
+ if (d && d.ok) { state.bpExt = d; if (repaint) paintBody() }
4667
+ }).catch(function () { /* 拉不到就按"未就绪"显示路径 */ })
4466
4668
  }
4467
4669
  function saveBotplay() {
4468
4670
  var hint = panel.querySelector('#dk-bp-hint')
@@ -4546,6 +4748,7 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
4546
4748
  if (mode === 'more' && state.chatOldest > 0) q += '&beforeSeq=' + state.chatOldest
4547
4749
  api('chat/history', q).then(function (d) {
4548
4750
  state.chatBusy = ''
4751
+ try { state.chatDebug = (d && d.debug) || null } catch (e) { /* ignore */ }
4549
4752
  if (!d || !d.ok) { state.chatErr = (d && (d.error || d.msg)) || '加载失败'; renderChatList(); return }
4550
4753
  var list = (Array.isArray(d.items) ? d.items : []).filter(function (it) {
4551
4754
  return it && ((typeof it.text === 'string' && it.text) || (Array.isArray(it.images) && it.images.length))
@@ -4623,6 +4826,9 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
4623
4826
  else if (state.chatErr) st.textContent = state.chatErr
4624
4827
  else if (!state.chatItems.length) st.textContent = '暂无记录 —— 机器人和该目标聊过后会显示在这里'
4625
4828
  else st.textContent = '共 ' + state.chatItems.length + ' 条 · ' + chatPeerName() + (state.chatMore ? ' · 上滑加载更早' : ' · 已到最早')
4829
+ + (state.chatDebug ? ' |🔍 ' + state.chatDebug.seq + '/' + state.chatDebug.eventsLen + '/' + state.chatDebug.got : '')
4830
+ // 🔍 临时诊断(2026-10-04,定位"只显示最旧"用):鼠标悬停看完整信息
4831
+ try { if (state.chatDebug) st.title = JSON.stringify(state.chatDebug) } catch (e) { /* ignore */ }
4626
4832
  }
4627
4833
  // 2026-10-01: 单条气泡 HTML 抽成函数 —— renderChatList 与"增量追加"共用同一套渲染
4628
4834
  function chatRowHTML(it) {
@@ -4699,20 +4905,83 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
4699
4905
  bindChatImgs(box)
4700
4906
  // 滚动: 刷新/切目标 → 滚到底; 上滚加载更早 → 保持视口
4701
4907
  if (keepScrollOffset == null) {
4702
- box.scrollTop = box.scrollHeight
4703
4908
  // 2026-10-01 主人实测「打开 dock 显示的不是最新消息」:
4704
4909
  // 气泡里的图是 loading="lazy",刚渲染时高度还没撑开 → 首次贴底其实只贴到"半截",
4705
- // 等图加载完内容变高,视口就停在中间了。这里随后再补贴几次;
4706
- // 用户一旦手动滚(wheel)立刻停手,不跟他抢滚动条。
4707
- var stickCancel = false
4708
- try { box.addEventListener('wheel', function () { stickCancel = true }, { once: true, passive: true }) } catch (e) { /* 老浏览器忽略 */ }
4709
- var stickBottom = function () { if (!stickCancel) box.scrollTop = box.scrollHeight }
4910
+ // 等图加载完内容变高,视口就停在中间了。
4911
+ // 2026-10-04 二次修复(主人又实测到:57 条带图的会话,打开后停在**最旧**那条):
4912
+ // 原来的"定时补贴 4 次(最多 1.2 秒)"在图片加载慢时窗口早关了 ⇒ 之后图片撑开就再没人管。
4913
+ // 改**事件驱动**:① 未加载完的 img 在 load/error 后贴底;
4914
+ // ② ResizeObserver 盯内容高度,一变高就贴底(覆盖懒加载/字体/视频等一切撑高)。
4915
+ //
4916
+ // 2026-10-04 三次修复(主人定位:**"自从记录滚动位置后,打开就不再默认最新了"**):
4917
+ // 上一版用 wheel / touchstart / keydown 当"用户在滚"的判据 —— 太容易误触发
4918
+ // (触控板轻点、焦点在面板里按键,甚至 image 的可聚焦元素都算),
4919
+ // 一旦被 cancel,`box.innerHTML = html` 后的新容器 scrollTop 就停在 0 = **最旧那条** ✗
4920
+ // 现在只看**真实滚动方向**:scrollTop 变小 = 用户主动往上翻 ⇒ 才停手。
4921
+ // 其他一律继续贴底,等于恢复"打开/刷新默认停在最新"的老行为。
4922
+ var lastTop = box.scrollTop
4923
+ var userScrolledUp = false
4924
+ try {
4925
+ box.addEventListener('scroll', function () {
4926
+ var t = box.scrollTop
4927
+ if (t < lastTop - 6) userScrolledUp = true // 往上翻 → 停手,别跟他抢
4928
+ else if (t + 6 >= box.scrollHeight - box.clientHeight) userScrolledUp = false // 又回到底部 → 恢复跟随
4929
+ lastTop = t
4930
+ }, { passive: true })
4931
+ } catch (e) { /* 老浏览器忽略 */ }
4932
+ var stickBottom = function () { if (!userScrolledUp) box.scrollTop = box.scrollHeight }
4933
+ box.scrollTop = box.scrollHeight
4934
+ // ① 图片加载完成 → 贴底
4935
+ try {
4936
+ var imgs = box.querySelectorAll('img')
4937
+ for (var ii = 0; ii < imgs.length; ii++) {
4938
+ if (imgs[ii].complete) continue
4939
+ imgs[ii].addEventListener('load', stickBottom, { once: true })
4940
+ imgs[ii].addEventListener('error', stickBottom, { once: true })
4941
+ }
4942
+ } catch (e) { /* ignore */ }
4943
+ // ② 内容高度变化 → 贴底
4944
+ // 2026-10-04 四次修复(主人实测"刷新后打开 dock 还是停在 9/21 最旧处"):
4945
+ // 上一版用 ResizeObserver 观测 `box`(#dk-chat-box)—— **错的**:
4946
+ // box 的高度是 CSS 写死的(height:min(36vh,300px)),图片撑高的是**内容**不是**容器**,
4947
+ // 所以回调**永远不会触发**,一旦 1.2s 的定时补贴过去就再没人贴底 ✗
4948
+ // 现在改为 **rAF 轮询 box.scrollHeight**(内容高度的直接度量):
4949
+ // 只要它在变(图片/字体/视频陆续撑开),就一直贴底;5 秒后自动停。
4950
+ // 期间用户若真的往上翻(userScrolledUp)立刻停手。
4951
+ try {
4952
+ (function () {
4953
+ var t0 = Date.now()
4954
+ var lastH = box.scrollHeight
4955
+ var tick = function () {
4956
+ if (userScrolledUp) return
4957
+ var h = box.scrollHeight
4958
+ if (h !== lastH) {
4959
+ lastH = h
4960
+ box.scrollTop = box.scrollHeight
4961
+ }
4962
+ if (Date.now() - t0 < 5000) requestAnimationFrame(tick)
4963
+ }
4964
+ requestAnimationFrame(tick)
4965
+ })()
4966
+ } catch (e) { /* 没有 rAF 的环境忽略(下面还有定时补贴兜底) */ }
4967
+ // ③ 保底定时补贴(覆盖没有 rAF 的环境)
4710
4968
  setTimeout(stickBottom, 80)
4711
4969
  setTimeout(stickBottom, 240)
4712
4970
  setTimeout(stickBottom, 600)
4713
4971
  setTimeout(stickBottom, 1200)
4972
+ setTimeout(stickBottom, 2500)
4714
4973
  }
4715
4974
  else box.scrollTop = box.scrollHeight - keepScrollOffset
4975
+ // 「↓ 最新」按钮:渲染即贴底 ⇒ 默认收起;并按输入框实际高度重新定位
4976
+ // (全屏 / 小屏 / textarea 变高时都不会压住输入区)
4977
+ try {
4978
+ var jbR = document.getElementById('dk-jump-latest')
4979
+ if (jbR) {
4980
+ jbR.classList.remove('on')
4981
+ var comp = document.querySelector('#qqs-dock-panel .dk-composer')
4982
+ jbR.style.bottom = (((comp && comp.offsetHeight) ? comp.offsetHeight : 66) + 10) + 'px'
4983
+ }
4984
+ } catch (e) { /* ignore */ }
4716
4985
  }
4717
4986
  // 2026-10-01 主人要求:滚轮向下 → **增量**追加新消息
4718
4987
  // · 只 append 新气泡(不 box.innerHTML 重绘 → **不闪**)
@@ -4725,6 +4994,9 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
4725
4994
  if (tail) tail.insertAdjacentHTML('beforebegin', html)
4726
4995
  else box.insertAdjacentHTML('beforeend', html)
4727
4996
  bindChatImgs(box)
4997
+ // 追加内容**不会触发 scroll 事件** ⇒ 主动同步一次「↓ 最新」按钮的显隐
4998
+ // (不含图片:图撑开导致的高度变化由上面 renderChatList 的 ResizeObserver 管)
4999
+ try { syncJumpLatest(box) } catch (e) { /* ignore */ }
4728
5000
  }
4729
5001
  // 滚轮触发的"取更新的消息":afterSeq = 当前已加载的最新位置
4730
5002
  function loadChatNewer() {
@@ -86,6 +86,32 @@ export declare class GroupAdminClient {
86
86
  constructor(opts: GroupAdminOptions);
87
87
  private gate;
88
88
  private call;
89
+ /**
90
+ * 获取【单个】群成员信息 —— 重点是它返回 `username`(昵称)。
91
+ *
92
+ * ⚠️ 2026-10-05:官方文档对该接口标注「该能力正在内邀接入中,敬请期待」,
93
+ * 错误码 11253 = 应用无接口访问权限(仅白名单机器人可用)。
94
+ * 所以它只适合当**兜底**:调用方拿到 FEATURE_NOT_OPEN / 11253 就静默回落,别报错。
95
+ * 一旦申请到权限,把 FEATURE_GATES.getMemberInfo.open 改 true 即自动生效。
96
+ */
97
+ /**
98
+ * 【给自定义事件模块用】带 token 的**通用**官方 API 调用。
99
+ *
100
+ * 价值:框架负责 access_token 的获取与缓存(per-appId + 并发单飞)—— 这是模块自己做不到的
101
+ * (它拿不到 appSecret)。模块只管给 path,就能调任意官方接口:查成员、发消息、撤回、
102
+ * 上传媒体、禁言、入群审批……
103
+ *
104
+ * @param method GET/POST/PATCH/DELETE
105
+ * @param path 不含 host 的路径,形如 /v2/groups/{group_openid}/members/{member_openid}
106
+ * @param body 可选 JSON body
107
+ * @returns { ok:true, data } | { ok:false, err:{ code, human } }(human 已是人话)
108
+ *
109
+ * ⚠️ 2026-10-05:**不做**能力门控(FEATURE_GATES 是给"已知未开放"的主流程用的);
110
+ * 未开放的接口会走官方错误码(如 11253),调用方自己看 err.code 判定。
111
+ * path 也不做白名单(模块是主人自己写的代码,沙箱边界不在这一层)。
112
+ */
113
+ apiCall(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<ApiResult<unknown>>;
114
+ getMemberInfo(gid: string, memberOpenid: string): Promise<ApiResult<MemberInfo>>;
89
115
  /** 获取群成员列表(🔴 未开放, 完整实现保留, 开放即用) */
90
116
  listMembers(gid: string, cursor?: string): Promise<ApiResult<{
91
117
  members: MemberInfo[];