@zaofan/dsh-qqbot 1.5.28 → 1.5.31

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 (42) hide show
  1. package/README_EN.md +24 -24
  2. package/client/qqbot-settings.js +142 -26
  3. package/client/qqbot-settings.js.bak-inc-1790865292905 +4967 -0
  4. package/client/qqbot-settings.js.bak-inc-1790865309979 +4967 -0
  5. package/client/qqbot-settings.js.bak-newer-1790865544006 +5023 -0
  6. package/client/qqbot-settings.js.bak-stick-1790865591295 +5033 -0
  7. package/dist/channel-tools.d.ts.map +1 -1
  8. package/dist/channel-tools.js +10 -0
  9. package/dist/channel-tools.js.map +1 -1
  10. package/dist/features/chat-ledger.d.ts +13 -0
  11. package/dist/features/chat-ledger.d.ts.map +1 -1
  12. package/dist/features/chat-ledger.js +52 -0
  13. package/dist/features/chat-ledger.js.map +1 -1
  14. package/dist/gateway/debounce.d.ts.map +1 -1
  15. package/dist/gateway/debounce.js +32 -2
  16. package/dist/gateway/debounce.js.map +1 -1
  17. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  18. package/dist/gateway/middleware-setup.js +25 -5
  19. package/dist/gateway/middleware-setup.js.map +1 -1
  20. package/dist/middleware/media-history.d.ts +27 -0
  21. package/dist/middleware/media-history.d.ts.map +1 -1
  22. package/dist/middleware/media-history.js +19 -5
  23. package/dist/middleware/media-history.js.map +1 -1
  24. package/dist/transport/inbound.d.ts +33 -0
  25. package/dist/transport/inbound.d.ts.map +1 -1
  26. package/dist/transport/inbound.js +232 -28
  27. package/dist/transport/inbound.js.map +1 -1
  28. package/dist/transport/msg-content-cache.d.ts +15 -0
  29. package/dist/transport/msg-content-cache.d.ts.map +1 -0
  30. package/dist/transport/msg-content-cache.js +105 -0
  31. package/dist/transport/msg-content-cache.js.map +1 -0
  32. package/dist/transport/quote-images.d.ts +17 -0
  33. package/dist/transport/quote-images.d.ts.map +1 -0
  34. package/dist/transport/quote-images.js +91 -0
  35. package/dist/transport/quote-images.js.map +1 -0
  36. package/dist/transport/quote-text.d.ts +30 -0
  37. package/dist/transport/quote-text.d.ts.map +1 -0
  38. package/dist/transport/quote-text.js +119 -0
  39. package/dist/transport/quote-text.js.map +1 -0
  40. package/docs/USER-GUIDE.md +118 -115
  41. package/package.json +1 -1
  42. package/settings-host.js +116 -16
@@ -72,24 +72,24 @@ npx @deepseek-ai/dsh web --patch /path/to/dsh-qqbot/cordis.dev.yml
72
72
 
73
73
  > 思路来源: wang-22-code/dsh-qqbot-bridge 的 QQ 审批设计(宿主 dsh `approval/request` 标准事件,官方 dsh-acp / Web 审批弹窗同款机制)。
74
74
 
75
-
76
- ### 审批卡片:谁可以点(v1.5.12+)
77
-
78
- 发起审批的卡片**发到发起者所在的会话**(群里就发到群里),但**只有下列人能点按钮**:
79
-
80
- 1. **发起者本人**
81
- 2. **主人白名单** —— `groupAdmin.owners`
82
-
83
- > ⚠️ **群聊场景一定要填白名单**:设置 → QQ 机器人 →「允许操作的主人 openid(逗号分隔,可留空=不校验)」。
84
- > 否则别人发起的审批,主人点了没反应(卡片只认发起者)。
85
- > 私聊场景不受影响(本就只有双方)。
86
-
87
- **这个白名单同时用于**:群管理工具的权限校验 + 审批卡片的可点名单(同一个"主人"概念)。
88
-
89
- **排障**:若点了按钮后"本轮运行失败",且日志里有
90
- `SessionFormatError: format v4 message requires a producer-owned source kind` ——
91
- 说明插件版本 < 1.5.12(旧代码用了 dsh V4 禁止的 `source.kind='plugin'`),升级即可。
92
-
75
+
76
+ ### 审批卡片:谁可以点(v1.5.12+)
77
+
78
+ 发起审批的卡片**发到发起者所在的会话**(群里就发到群里),但**只有下列人能点按钮**:
79
+
80
+ 1. **发起者本人**
81
+ 2. **主人白名单** —— `groupAdmin.owners`
82
+
83
+ > ⚠️ **群聊场景一定要填白名单**:设置 → QQ 机器人 →「允许操作的主人 openid(逗号分隔,可留空=不校验)」。
84
+ > 否则别人发起的审批,主人点了没反应(卡片只认发起者)。
85
+ > 私聊场景不受影响(本就只有双方)。
86
+
87
+ **这个白名单同时用于**:群管理工具的权限校验 + 审批卡片的可点名单(同一个"主人"概念)。
88
+
89
+ **排障**:若点了按钮后"本轮运行失败",且日志里有
90
+ `SessionFormatError: format v4 message requires a producer-owned source kind` ——
91
+ 说明插件版本 < 1.5.12(旧代码用了 dsh V4 禁止的 `source.kind='plugin'`),升级即可。
92
+
93
93
  ## QQ 群管理(可选)
94
94
 
95
95
  机器人**为群管理员**时,可开启群管理能力:实时接收「入群申请」并自动提醒主人、按申请审批入群、查询/设置群成员禁言。所有操作走腾讯官方 GroupOpenMsg 接口,错误信息已做"人话"映射(如 11703=机器人不是该群管理员、40103004=不能禁言群主/管理员、11255=群已注销)。
@@ -201,43 +201,43 @@ Invoke-WebRequest "$base/config.json" -OutFile "$dir\config.json"
201
201
  - **拦截边界**:被 @ 的永远放行;带图一律放行;**只有被拦在唤醒之前**(没花 token)的那次才会**回滚**本群的回复冷却 —— 冷却本来就是用来省 token 的,token 花了就不算白花。
202
202
  - **评分记录**:`{dataRoot}/.qqbot/value-scores.jsonl`,一行一条,字段 `score / worth / gate / min / conf / mention / img / lib / agg / top`。面板能直读,也可以让 AI 读它来维护样例库(`{dataRoot}/.qqbot/value-samples.jsonl`:`{"m":"消息文本","y":1|0}`,y=1 表示"她会想接话")。
203
203
 
204
-
205
- ## 🚫 无上下文模式(按会话省 token)(v1.5.11+)
206
-
207
- 让某个 QQ 会话**每轮只记得最近几条对话**,更早的历史自动折叠 —— 群聊省 token 利器。
208
-
209
- ### 怎么开
210
-
211
- 1. 打开 dock 面板(右下角 🛡)→「⚙ **单会话设置**」→ 子标签「🚫 **无上下文**」
212
- 2. 确认面板上显示的"作用对象"是你想设的那个群
213
- 3. 勾选「无上下文模式」,填「带 @ 前 N 条」,点「💾 保存(本会话)」
214
- - 保存成功会显示「✅ 已保存并回读确认: 开启 / 带 N 条」
215
-
216
- ### 行为说明(几个容易误解的地方)
217
-
218
- | 现象 | 说明 |
219
- |---|---|
220
- | **web 上还能翻到旧对话** | ✅ 正常。折叠的只是「模型视野」,**会话记录本身不删**;web 里会多一行「上下文已压缩 · 已压缩 N 条历史记录」的折叠标记 |
221
- | **N 条 ≠ N 条 QQ 消息** | ⚠️ N 计的是 **dsh 侧的消息条数**。插件入站时会把"群历史 + 当前消息"**聚合成一条**,所以 1 条可能含多条 QQ 消息(群历史缓冲上限由 `historyLimit` 控制,默认 10) |
222
- | **群守则每轮都重新注入** | ✅ 正常且必要。压缩会把旧的守则一起折叠,所以每轮补一份;**改了群守则会立刻生效**(去重按内容比较) |
223
- | **什么时候生效** | 压缩发生在**回合开始**,所以**从下一轮起**才看不到更早的对话(当轮她已经装好上下文了) |
224
- | **会调额外的模型吗** | ❌ 不会。替身文本是写死的,**零 LLM 调用**(比 dsh 自带 compaction"生成摘要"更省) |
225
-
226
- ### 全局开启(可选)
227
-
228
- 不逐会话设置也可以开全局:
229
-
230
- ```yaml
231
- contextlessMode: true # 所有会话都启用
232
- contextlessWindow: 5 # 保留最近 5 条
233
- ```
234
-
235
- ### 存储位置
236
-
237
- `{dataRoot}/.qqbot/contextless.json` —— 键是会话(`qqbot:<appId>:group:<群openid>`),值 `{ enabled, window }`。
238
-
239
- > ⚠️ 别手改这个文件(面板保存会回读校验);想全关就把每个 key 的 `enabled` 改成 `false`。
240
-
204
+
205
+ ## 🚫 无上下文模式(按会话省 token)(v1.5.11+)
206
+
207
+ 让某个 QQ 会话**每轮只记得最近几条对话**,更早的历史自动折叠 —— 群聊省 token 利器。
208
+
209
+ ### 怎么开
210
+
211
+ 1. 打开 dock 面板(右下角 🛡)→「⚙ **单会话设置**」→ 子标签「🚫 **无上下文**」
212
+ 2. 确认面板上显示的"作用对象"是你想设的那个群
213
+ 3. 勾选「无上下文模式」,填「带 @ 前 N 条」,点「💾 保存(本会话)」
214
+ - 保存成功会显示「✅ 已保存并回读确认: 开启 / 带 N 条」
215
+
216
+ ### 行为说明(几个容易误解的地方)
217
+
218
+ | 现象 | 说明 |
219
+ |---|---|
220
+ | **web 上还能翻到旧对话** | ✅ 正常。折叠的只是「模型视野」,**会话记录本身不删**;web 里会多一行「上下文已压缩 · 已压缩 N 条历史记录」的折叠标记 |
221
+ | **N 条 ≠ N 条 QQ 消息** | ⚠️ N 计的是 **dsh 侧的消息条数**。插件入站时会把"群历史 + 当前消息"**聚合成一条**,所以 1 条可能含多条 QQ 消息(群历史缓冲上限由 `historyLimit` 控制,默认 10) |
222
+ | **群守则每轮都重新注入** | ✅ 正常且必要。压缩会把旧的守则一起折叠,所以每轮补一份;**改了群守则会立刻生效**(去重按内容比较) |
223
+ | **什么时候生效** | 压缩发生在**回合开始**,所以**从下一轮起**才看不到更早的对话(当轮她已经装好上下文了) |
224
+ | **会调额外的模型吗** | ❌ 不会。替身文本是写死的,**零 LLM 调用**(比 dsh 自带 compaction"生成摘要"更省) |
225
+
226
+ ### 全局开启(可选)
227
+
228
+ 不逐会话设置也可以开全局:
229
+
230
+ ```yaml
231
+ contextlessMode: true # 所有会话都启用
232
+ contextlessWindow: 5 # 保留最近 5 条
233
+ ```
234
+
235
+ ### 存储位置
236
+
237
+ `{dataRoot}/.qqbot/contextless.json` —— 键是会话(`qqbot:<appId>:group:<群openid>`),值 `{ enabled, window }`。
238
+
239
+ > ⚠️ 别手改这个文件(面板保存会回读校验);想全关就把每个 key 的 `enabled` 改成 `false`。
240
+
241
241
  ## 💗 好感度与熟识度(v1.5.0+)
242
242
 
243
243
  两个维度**分开算、互不干扰**:
@@ -399,7 +399,7 @@ export default {
399
399
  inputSchema: { // ⚠️ 可选参数不要写 required; 必填才写 required: true
400
400
  sides: { type: 'integer', description: '骰子面数, 默认 6' },
401
401
  },
402
- // env: { cwd, manager, sender, replyTarget, exec } —— sender/replyTarget 可发 QQ 消息
402
+ // env: { cwd, manager, sender, replyTarget, exec, ctx, logger } —— sender/replyTarget 可发 QQ 消息; ctx 是宿主上下文
403
403
  run: async (args, env) => {
404
404
  const sides = Math.max(2, Math.min(1000, Math.round(Number(args.sides) || 6)));
405
405
  return { ok: true, msg: `🎲 ${1 + Math.floor(Math.random() * sides)}` };
@@ -412,8 +412,11 @@ export default {
412
412
  1. 命令/工具文件都放**账号数据目录**的 `.qqbot-extensions/` 下(dataRoot 优先, 无则 cwd), 别放插件包内。
413
413
  → **升级/重装插件(换 node_modules)只动插件本体, 不会覆盖扩展目录**, 用户的扩展永久保留。
414
414
  2. 工具入参 schema 用 JSON Schema 风格; **可选参数不带 required 字段**。
415
- 3. `run(args, env)` 的 `env = { cwd, manager, sender, replyTarget, exec }`:
415
+ 3. `run(args, env)` 的 `env = { cwd, manager, sender, replyTarget, exec, ctx, logger }`:
416
416
  - `sender` + `replyTarget` 就是内置 `send_media` 用的发送器 → **工具可以自己发 markdown 卡片 / 图片 / 语音 / 文件**, 不用把内容再交回 AI。
417
+ - `ctx` 是**宿主的插件上下文**(与内置工具同源, 1.5.30 起提供) → 扩展工具能自助调用宿主能力, 例如
418
+ `ctx.compaction.compactNow(agent, exec.signal, id)` 压缩上下文、`ctx.get('服务名')` 探测可选服务; `logger` 是对应日志器。
419
+ ⚠️ 权限与内置工具**同级** —— 只适合**你自己写在 dataRoot 里的**扩展, 别把 `ctx` 转手给不可信代码。
417
420
  - 工具返回 `{ ok, msg }`(msg 作为工具结果回给 AI); 命令返回纯文本。
418
421
  4. 卡片正文由**你(AI)直接写 markdown**(标题/加粗/`![说明](url)`/代码块), **本插件没有模板引擎, 不需要也不会用配置型模板**。
419
422
  5. 生效方式: 工具发 `/tools-reload` 或调 `tools_reload` —— 新工具即时生效; **同名工具改内容会被工具注册表跳过(`already registered`) → 换名或重启宿主**; 命令一律需重启宿主(`/bot-restart`)。
@@ -489,64 +492,64 @@ sessionKey: `qqbot:${appId}:${scope}:${peerId}`,由 SHA-256 确定性派生 Se
489
492
 
490
493
  解析策略:进程内复用 → 持久化恢复 → 全新创建。
491
494
 
492
-
493
- ## 常见问题(详解)
494
-
495
- ### Q: 升级 dsh 0.1.7 后,恢复会话报「预设缺失」/ 机器人不理人?
496
-
497
- **原因**:0.1.7 把「Agent 预设」从"目录里的文件"改成了"profile 里的声明行",旧的预设目录不再被扫描。
498
-
499
- **处理**:用插件自带的迁移脚本把预设迁到 profile 声明里(或重新在「Agent 预设」页里建一次),然后重启 dsh。
500
-
501
- ### Q: 升级 dsh 0.1.7 后,某些插件被「禁用 / 跳过」?
502
-
503
- **原因**:0.1.7 起按 `peerDependencies` 校验插件与宿主兼容性,声明不匹配的会被跳过(打印 `skipping profile bundle ...`)。
504
-
505
- **处理**:升级该插件,或按提示用 `dsh plugin allow-version` 显式豁免。
506
-
507
- ### Q: 升级后我自定义的配置(群守则 / 注入规则 / 互动事件)被默认值顶掉了,能找回吗?
508
-
509
- **能。** 0.1.7 会把 `~/.dsh/settings.yaml` 一次性导入 profile,但**导入不一定落到 entry config 里**;
510
- 插件读不到自定义值就用默认值回写,把你的配置顶掉。
511
-
512
- **找回步骤**:
513
- 1. 打开 `~/.dsh/settings.yaml.imported`(同目录通常还有 `.bak`)
514
- 2. 找到你实例的 section(如 `im-qqbot-2:`),再找对应字段(`groupPrompt:` / `injectRules:` / `botplayEvents:` …)
515
- 3. 复制内容,回 Web 设置页粘回去保存
516
- (1.5.9 起配置存在插件自有存储,不会再被默认值覆盖)
517
-
518
- > ⚠️ 群守则里常含私人信息(主人 openid、进群暗号等),**发布/分享/截图时记得剔除**。
519
-
520
- ### Q: 设置页保存后,为什么 patch 里看不到我的改动?
521
-
522
- **这是有意设计**。设置页保存**不写 profile 的 `cordis.patch.yml`**,而是写插件自己的存储:
523
- `{DSH_HOME}/qqbot-settings/<实例 id>.json`。
524
-
525
- **为什么**:写 patch 会被宿主当成热更新提交 → 插件重新加载 → 若凭据无效又会写 → 形成死循环(实测能把 dsh 启动刷死)。
526
- 另外手拼 YAML 也保不住字段类型(已实测出 `expected string` / `expected array` 等问题)。
527
-
528
- **读取顺序**:patch 值 + 自有存储覆盖(后者优先)。
529
-
530
- ### Q: 面板上「实时入群事件」开关是干什么的?打开后机器人连不上了?
531
-
532
- **原因**:这个开关会订阅 QQ 的 `GROUP_JOIN_REQUEST` 事件(intent `1<<24`)。
533
- **若开放平台没有开通该事件,网关会拒绝连接(close 4914/4915)→ 机器人直接离线**。
534
-
535
- **正确处理顺序**:**① 平台开通事件 → ② 打开开关 → ③ 重启**。
536
-
537
- **不想折腾平台**:用「轮询入群申请」(不需要任何授权):
538
- - `轮询入群申请` 是**总开关**,不勾它,下面的选项都不生效
539
- - 「唤醒 AI」与「注入群管会话」**互斥且唤醒优先** —— 两个都勾只会唤醒 AI,不会注入群管会话
540
- - 想要静默注入群管会话,就**只勾「注入群管会话」**
541
-
542
- ### Q: 为什么自有存储里不要出现空对象(`{}`)?
543
-
544
- **原因**:合并是递归深合并,但**空对象会把整块默认配置顶掉**。
545
- 例如 `sticker: {}` 会顶掉 patch 里的整个 `sticker` 块 → `collectEnabled` 变成 `undefined` → **图片自动下载静默停摆**
546
- (症状:最后一张自动下载的图停在某个时刻,之后入站图片只剩 QQ 长 URL)。
547
-
548
- **处理**:**想恢复默认就删掉该字段,不要写 `{}`**。
549
-
495
+
496
+ ## 常见问题(详解)
497
+
498
+ ### Q: 升级 dsh 0.1.7 后,恢复会话报「预设缺失」/ 机器人不理人?
499
+
500
+ **原因**:0.1.7 把「Agent 预设」从"目录里的文件"改成了"profile 里的声明行",旧的预设目录不再被扫描。
501
+
502
+ **处理**:用插件自带的迁移脚本把预设迁到 profile 声明里(或重新在「Agent 预设」页里建一次),然后重启 dsh。
503
+
504
+ ### Q: 升级 dsh 0.1.7 后,某些插件被「禁用 / 跳过」?
505
+
506
+ **原因**:0.1.7 起按 `peerDependencies` 校验插件与宿主兼容性,声明不匹配的会被跳过(打印 `skipping profile bundle ...`)。
507
+
508
+ **处理**:升级该插件,或按提示用 `dsh plugin allow-version` 显式豁免。
509
+
510
+ ### Q: 升级后我自定义的配置(群守则 / 注入规则 / 互动事件)被默认值顶掉了,能找回吗?
511
+
512
+ **能。** 0.1.7 会把 `~/.dsh/settings.yaml` 一次性导入 profile,但**导入不一定落到 entry config 里**;
513
+ 插件读不到自定义值就用默认值回写,把你的配置顶掉。
514
+
515
+ **找回步骤**:
516
+ 1. 打开 `~/.dsh/settings.yaml.imported`(同目录通常还有 `.bak`)
517
+ 2. 找到你实例的 section(如 `im-qqbot-2:`),再找对应字段(`groupPrompt:` / `injectRules:` / `botplayEvents:` …)
518
+ 3. 复制内容,回 Web 设置页粘回去保存
519
+ (1.5.9 起配置存在插件自有存储,不会再被默认值覆盖)
520
+
521
+ > ⚠️ 群守则里常含私人信息(主人 openid、进群暗号等),**发布/分享/截图时记得剔除**。
522
+
523
+ ### Q: 设置页保存后,为什么 patch 里看不到我的改动?
524
+
525
+ **这是有意设计**。设置页保存**不写 profile 的 `cordis.patch.yml`**,而是写插件自己的存储:
526
+ `{DSH_HOME}/qqbot-settings/<实例 id>.json`。
527
+
528
+ **为什么**:写 patch 会被宿主当成热更新提交 → 插件重新加载 → 若凭据无效又会写 → 形成死循环(实测能把 dsh 启动刷死)。
529
+ 另外手拼 YAML 也保不住字段类型(已实测出 `expected string` / `expected array` 等问题)。
530
+
531
+ **读取顺序**:patch 值 + 自有存储覆盖(后者优先)。
532
+
533
+ ### Q: 面板上「实时入群事件」开关是干什么的?打开后机器人连不上了?
534
+
535
+ **原因**:这个开关会订阅 QQ 的 `GROUP_JOIN_REQUEST` 事件(intent `1<<24`)。
536
+ **若开放平台没有开通该事件,网关会拒绝连接(close 4914/4915)→ 机器人直接离线**。
537
+
538
+ **正确处理顺序**:**① 平台开通事件 → ② 打开开关 → ③ 重启**。
539
+
540
+ **不想折腾平台**:用「轮询入群申请」(不需要任何授权):
541
+ - `轮询入群申请` 是**总开关**,不勾它,下面的选项都不生效
542
+ - 「唤醒 AI」与「注入群管会话」**互斥且唤醒优先** —— 两个都勾只会唤醒 AI,不会注入群管会话
543
+ - 想要静默注入群管会话,就**只勾「注入群管会话」**
544
+
545
+ ### Q: 为什么自有存储里不要出现空对象(`{}`)?
546
+
547
+ **原因**:合并是递归深合并,但**空对象会把整块默认配置顶掉**。
548
+ 例如 `sticker: {}` 会顶掉 patch 里的整个 `sticker` 块 → `collectEnabled` 变成 `undefined` → **图片自动下载静默停摆**
549
+ (症状:最后一张自动下载的图停在某个时刻,之后入站图片只剩 QQ 长 URL)。
550
+
551
+ **处理**:**想恢复默认就删掉该字段,不要写 `{}`**。
552
+
550
553
  ## 设计原则
551
554
 
552
555
  - **纯 Cordis 插件** — 遵循 dsh "Plugins, not loop changes" 原则
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaofan/dsh-qqbot",
3
- "version": "1.5.28",
3
+ "version": "1.5.31",
4
4
  "description": "QQ Bot IM channel plugin for deepseek-harness (dsh)",
5
5
  "type": "module",
6
6
  "main": "./entry.js",
package/settings-host.js CHANGED
@@ -2888,13 +2888,28 @@ export function apply(ctx) {
2888
2888
  // ⚠️ **两套都要认**: 老会话里的历史消息是渲染时解析的, 只认新标记会让旧记录显示成一堆裸标记。
2889
2889
  const RE_TIME_HEAD = /^\s*\[(?:\d{4}-\d{2}-\d{2}\s[^\]]*|当前时间[^\]]*)\]\s*\r?\n?/;
2890
2890
  const isTimeHeadLine = (s) => /^\[(?:\d{4}-\d{2}-\d{2}\s|当前时间)/.test(s);
2891
- const RE_HIST_BEGIN_LINE = /^\[(?:历史|Chat history begins)\]$/;
2892
- const RE_HIST_END_LINE = /^\[\/(?:历史|Chat history ends)\]$/; // 老标记没有斜杠
2893
- const RE_CURRENT_LINE = /^\[(?:当前|Current message)\]$/;
2891
+ // ⚠️ 标记行一律**大小写不敏感**(老英文标记会被写成小写), 且老结束标记 `[Chat history ends]` **没有斜杠**。
2892
+ const RE_HIST_BEGIN_LINE = /^\[(?:历史|Chat history begins)\]$/i;
2893
+ const RE_HIST_END_LINE = /^\[\/?(?:历史|Chat history ends)\]$/i;
2894
+ const RE_CURRENT_LINE = /^\[(?:当前|Current message)\]$/i;
2894
2895
  const RE_QUOTE_BEGIN = /\[(?:引|Quoted message begins)\]/i;
2895
2896
  const RE_QUOTE_END = /\[\/(?:引|Quoted message ends)\]/i;
2896
- const RE_CURRENT_ANY = /\[(?:当前|Current message)\]\s*/g;
2897
- const hasHistoryBlock = (s) => RE_HIST_BEGIN_LINE.test(String(s || '').trim()) || String(s || '').indexOf('[Chat history begins]') >= 0;
2897
+ const RE_CURRENT_ANY = /\[(?:当前|Current message)\]\s*/gi;
2898
+ // 历史块标记(新旧), 出现在正文里就整段抹掉 —— 内容已由 chatSplitHistoryBlock 拆成独立气泡
2899
+ const RE_HIST_MARK_ANY = /\[(?:\/?历史|\/?(?:Chat history begins|Chat history ends))\]/gi;
2900
+ // 这些词是**标记**不是昵称: 误当发送者时气泡会显示"历史"/"引用"(头像也跟着取错首字)
2901
+ const RE_MARKER_WORD = /^\/?(?:历|历史|当前|引|引用|Quoted message(?: begins| ends)?|Current message|Chat history(?: begins| ends)?)$/i;
2902
+ // ⚠️ 2026-10-01 修(主人 dock 聊天页截图暴露):
2903
+ // 原实现拿**整段 body.trim()** 去套"整行 == [历史]"的正则 → 对
2904
+ // "[历史]\n[昵称] …\n[/历史]\n[当前]\n…" 永远不命中 → 历史打包分支形同虚设, 后果三连:
2905
+ // ① tagM 把第一个方括号 `[历史]` 当成发送者 → 气泡发送者显示"历史"(头像"历");
2906
+ // ② `[/历史]` 原样留在末尾(前端只认 [当前]/[引]…[/引], 不认它);
2907
+ // ③ `[昵称] [图片: D:\…\x.gif]` 整行当正文 → 裸文本 + **本机绝对路径外泄**。
2908
+ // 改为**逐行**判定(老英文标记在行内任意位置也算)。
2909
+ const hasHistoryBlock = (s) => String(s || '').split(/\r?\n/).some((line) => {
2910
+ const t = line.trim();
2911
+ return RE_HIST_BEGIN_LINE.test(t) || /\[Chat history begins\]/i.test(t);
2912
+ });
2898
2913
  function chatPeelTimeHead(text) {
2899
2914
  // QQ 入站文本头形如 "[2026-09-15 周二 11:06]\n\n"(老版是 "[当前时间 2026-09-05 周六 19:08]"), 整体剥掉
2900
2915
  return String(text || '').replace(RE_TIME_HEAD, '');
@@ -3036,6 +3051,12 @@ export function apply(ctx) {
3036
3051
  // 优先取 "→ " 之后到行尾(路径里可能有空格); 没有箭头就在行内找绝对路径
3037
3052
  const arrow = l.match(/→\s*([\s\S]+?)\s*$/);
3038
3053
  let cand = arrow ? arrow[1].trim() : '';
3054
+ if (!cand) {
3055
+ // 2026-10-01: `[图片: <本机路径>]` / `[File: <本机路径>]` 整行 —— 路径可能含空格,
3056
+ // 下面按空白截断的正则会切坏它, 所以先按"冒号后到右括号"整体取一次
3057
+ const br = l.match(/^\s*\[[^\]::]+[::]\s*([\s\S]+?)\s*\]\s*$/);
3058
+ if (br && /(?:[A-Za-z]:[\\/]|^\\\\|\/)/.test(br[1])) cand = br[1].trim();
3059
+ }
3039
3060
  if (!cand) {
3040
3061
  const m2 = l.match(/([A-Za-z]:[\\/][^\s]+)/);
3041
3062
  cand = m2 ? m2[1] : '';
@@ -3239,7 +3260,12 @@ export function apply(ctx) {
3239
3260
  for (const line of String(body || '').split('\n')) {
3240
3261
  const s = line.trim();
3241
3262
  if (!s) continue;
3242
- if (RE_HIST_BEGIN_LINE.test(s) || RE_HIST_END_LINE.test(s) || RE_CURRENT_LINE.test(s) || isTimeHeadLine(s)) continue;
3263
+ // ⚠️ 2026-10-01 修(主人实测:"当前消息引用的消息显示给上一个人了"):
3264
+ // `[当前]` 是**分段点** —— 它之前属于历史(该收尾结算),之后的"引用块 + 正文"才是当前消息。
3265
+ // 原来这里直接 continue 不 flush,导致当前消息的 `[引]…[/引]` 被并进**上一条历史消息**里
3266
+ // (于是界面上引用块挂在别人气泡上)。
3267
+ if (RE_CURRENT_LINE.test(s)) { flush(); continue; }
3268
+ if (RE_HIST_BEGIN_LINE.test(s) || RE_HIST_END_LINE.test(s) || isTimeHeadLine(s)) continue;
3243
3269
  if (/^\[系统提示\]/.test(s)) { flush(); return out; } // 系统注入段, 之后都不属于群聊内容
3244
3270
  // 媒体元数据行: 暂存, 等归属给下面那条带昵称的消息
3245
3271
  // (Layer 4 现输出 `- Image:/- Video:/- Voice:/- File:` 单数前缀, 此处一并覆盖)
@@ -3247,7 +3273,23 @@ export function apply(ctx) {
3247
3273
  // 2026-09-12 适配(token 瘦身后历史行变成 "[昵称]"): id 段设为**可选**, 老格式 "[昵称 (openid)]" 仍认;
3248
3274
  // 同时排除含冒号的方括号(如 "[图片: url]"), 免得把附件标记当成发送者。
3249
3275
  const m = s.match(/^\[([^\]\n:]*?)(?:\s*\([A-Za-z0-9_-]{6,}\))?\](.*)$/);
3250
- if (m) { flush(); cur = { sender: m[1].trim(), lines: [m[2].trim(), ...pendingMeta].filter(Boolean) }; pendingMeta = []; }
3276
+ // ⚠️ 2026-10-01 修(主人实测:dock 聊天界面冒出个叫「引」的人发消息):
3277
+ // `[引]` / `[/引]` / `[引用]` 是**块标记**而不是昵称壳 —— 命中它们时不能开新气泡,
3278
+ // 要当普通行并入当前段(真昵称在后面几行,交给 chatPolishOne 去找)。
3279
+ // 与 chatPolishOne 的 RE_MARKER_WORD 同款防御,此处之前漏了。
3280
+ const isNick = m && m[1].trim() && !RE_MARKER_WORD.test(m[1].trim());
3281
+ if (isNick) {
3282
+ // ⚠️ 2026-10-01:当前消息的引用块会先形成一个 sender 为空的段 —— 紧随的 [昵称] 行要**接管**它
3283
+ // (引用块 + 正文同属一条消息),否则引用块会独立成一个没有名字的气泡。
3284
+ if (cur && !cur.sender) {
3285
+ cur.sender = m[1].trim();
3286
+ cur.lines.push(...[m[2].trim(), ...pendingMeta].filter(Boolean));
3287
+ } else {
3288
+ flush();
3289
+ cur = { sender: m[1].trim(), lines: [m[2].trim(), ...pendingMeta].filter(Boolean) };
3290
+ }
3291
+ pendingMeta = [];
3292
+ }
3251
3293
  else { if (cur) cur.lines.push(...pendingMeta, s); else { flush(); cur = { sender: '', lines: [...pendingMeta, s] }; } pendingMeta = []; }
3252
3294
  }
3253
3295
  flush();
@@ -3258,11 +3300,32 @@ export function apply(ctx) {
3258
3300
  function chatPolishOne(body, fallbackSender, nameByMid) {
3259
3301
  let sender = fallbackSender;
3260
3302
  let b = String(body || '');
3261
- b = b.replace(RE_CURRENT_ANY, '');
3262
- b = b.replace(new RegExp(`${RE_QUOTE_BEGIN.source}\\s*[\\s\\S]*?${RE_QUOTE_END.source}\\s*`, 'g'), '[引用]');
3263
- // 2026-09-12 适配: 同上 —— "[昵称]" 与 "[昵称 (openid)]" 都认; 排除含冒号的方括号(附件标记)
3264
- const tagM = b.match(/\[([^\]\n:]*?)(?:\s*\([A-Za-z0-9_-]{6,}\))?\]/);
3265
- if (tagM) { if (tagM[1].trim()) sender = tagM[1].trim(); b = b.replace(tagM[0], ''); }
3303
+ b = b.replace(RE_CURRENT_ANY, ' ');
3304
+ // 2026-10-01: 历史块标记(新旧)先抹掉 —— 内容已被 chatSplitHistoryBlock 拆成独立气泡,
3305
+ // 残留标记会被下面的 tagM 当发送者(气泡显示"历史"), 也会在末尾显示成裸文本
3306
+ b = b.replace(RE_HIST_MARK_ANY, ' ');
3307
+ // 2026-10-01: 引用块**不再折叠成 `[引用]`**(v0.8.0 的老做法) —— 前端 chatRenderText 本来就有
3308
+ // `[引]…[/引]` 的引用气泡样式(左边框+灰底), 折叠后原文丢失、只剩一个裸方括号,
3309
+ // 还会被下面的昵称提取当成发送者(气泡显示"引用")。这里先"摘出来占位",
3310
+ // 等昵称壳剥完再原样放回; 超长截断(被引用的原文可能有几百字)。
3311
+ const quotes = [];
3312
+ b = b.replace(new RegExp(`${RE_QUOTE_BEGIN.source}\\s*([\\s\\S]*?)\\s*${RE_QUOTE_END.source}`, 'g'), (_all, inner) => {
3313
+ const q = String(inner || '').trim();
3314
+ quotes.push('[引]' + (q.length > 300 ? q.slice(0, 300) + '…' : q) + '[/引]');
3315
+ return '\u0000Q' + (quotes.length - 1) + '\u0000';
3316
+ });
3317
+ // 2026-09-12 适配: "[昵称]" 与 "[昵称 (openid)]" 都认; 排除含冒号的方括号(附件标记)
3318
+ // 2026-10-01: **标记词**(`[引]`/`[历史]`/`[当前]`…)不算昵称, 跳过它继续往后找真昵称
3319
+ const reTag = /\[([^\]\n:]*?)(?:\s*\([A-Za-z0-9_-]{6,}\))?\]/g;
3320
+ let tmm;
3321
+ while ((tmm = reTag.exec(b)) !== null) {
3322
+ const nm = tmm[1].trim();
3323
+ if (!nm || RE_MARKER_WORD.test(nm)) continue;
3324
+ sender = nm;
3325
+ b = b.replace(tmm[0], '');
3326
+ break;
3327
+ }
3328
+ if (quotes.length) b = b.replace(/\u0000Q(\d+)\u0000/g, (_all, i) => quotes[Number(i)] || '');
3266
3329
  const sysIdx = b.indexOf('[系统提示]');
3267
3330
  if (sysIdx >= 0) b = b.slice(0, sysIdx).replace(/\s*$/, '');
3268
3331
  const mm = chatSplitMedia(chatDisplayClean(b, nameByMid));
@@ -3284,9 +3347,20 @@ export function apply(ctx) {
3284
3347
  const time = typeof ev.time === 'number' ? ev.time : 0;
3285
3348
  if (ev.type === 'user/message') {
3286
3349
  const src = data.source && typeof data.source === 'object' ? data.source : {};
3287
- if (src.kind === 'plugin') return null; // runtime context / 系统注入等, 非真人对话
3350
+ // ⚠️ 2026-10-01 修(主人截图: 整段 `<system-reminder>` 系统提示词漏进了 dock 聊天界面):
3351
+ // `user/message` 在 dsh 会话里**不只承载真人发言** —— 插件/宿主的运行时注入也走它:
3352
+ // · 固定通道规则 + 群守则 → source.kind `qqbot:group-rules`(session-manager 拼的 <system-reminder>)
3353
+ // · QQ 审批提醒 → source.kind `plugin:qqbot-approval`
3354
+ // · 上下文压缩检查点 → source.kind `compact-checkpoint`
3355
+ // · 其它插件/扩展工具 → source.kind `plugin:<name>`
3356
+ // 原实现只挡了 `kind === 'plugin'`(还是会话格式 V4 已废弃的写法) → 以上全漏。
3357
+ // 真人发言的 kind 是 `user`; 这里按"插件自有 kind 前缀"黑名单 + 内容特征双保险。
3358
+ const srcKind = typeof src.kind === 'string' ? src.kind : '';
3359
+ if (srcKind === 'plugin' || srcKind.startsWith('plugin:') || srcKind.startsWith('qqbot:') || srcKind === 'compact-checkpoint') return null;
3288
3360
  const raw0 = chatTextOf(data.content);
3289
3361
  if (!raw0) return null;
3362
+ // 兜底: 运行时上下文(user-role 快照)一律包在 <system-reminder> 里 → 不是对方说的话, 不进聊天视图
3363
+ if (/^\s*<system-reminder>/.test(raw0)) return null;
3290
3364
  chatRawDiag(raw0);
3291
3365
  const raw = chatPeelTimeHead(raw0);
3292
3366
  const isRelay = /^用户代你发送: /.test(raw);
@@ -3353,6 +3427,10 @@ export function apply(ctx) {
3353
3427
  const limit = Math.max(1, Math.min(200, Math.round(Number(u.searchParams.get('limit'))) || 50));
3354
3428
  const bRaw = Number(u.searchParams.get('beforeSeq'));
3355
3429
  const beforeSeq = Number.isFinite(bRaw) && bRaw > 0 ? Math.floor(bRaw) : undefined;
3430
+ // 2026-10-01 新增 afterSeq:**往后取更新的消息**(dock 聊天页滚轮增量刷新用 ——
3431
+ // 只 append 新气泡,不重绘不闪、不跳滚动位置)。与 beforeSeq 互斥:给了 afterSeq 就正扫。
3432
+ const aRaw = Number(u.searchParams.get('afterSeq'));
3433
+ const afterSeq = Number.isFinite(aRaw) && aRaw >= 0 ? Math.floor(aRaw) : undefined;
3356
3434
  const bot = nsBot(ns);
3357
3435
  if (!bot) return writeJson(res, 400, { error: '找不到该账号实例(请先在账号页配置 appId/appSecret)' });
3358
3436
  const reg = await import('./dist/features/session-registry.js');
@@ -3381,9 +3459,30 @@ export function apply(ctx) {
3381
3459
  const fallbackSender = scope === 'c2c' ? ((ledger.get('c2c:' + peerId) || {}).name || '') : '';
3382
3460
  // 尾部倒扫: 默认从最新一条事件 seq 往前; beforeSeq=加载更早(beforeSeq 之前的)
3383
3461
  const items = [];
3384
- let high = beforeSeq !== undefined ? Math.max(0, beforeSeq - 1) : Math.max(0, sess.seq - 1);
3385
- let reachedEnd = false;
3386
3462
  const STEP = 500;
3463
+ let reachedEnd = false;
3464
+ if (afterSeq !== undefined) {
3465
+ // ★ 正扫:从 afterSeq+1 扫到最新(升序)—— 只取"比前端已有的更新"的那批
3466
+ const top = Math.max(0, sess.seq - 1);
3467
+ let low = afterSeq + 1;
3468
+ while (low <= top && items.length < limit) {
3469
+ const hi = Math.min(top, low + STEP - 1);
3470
+ let evs = [];
3471
+ if (snap) { try { evs = snap(low, hi + 1) || []; } catch { evs = []; } }
3472
+ if (!evs.length) { const all = sess.events; if (Array.isArray(all) && all.length && all.length > low) evs = all.slice(low, hi + 1); }
3473
+ for (const ev of evs) {
3474
+ const got = chatDecodeEvent(ev, scope, nameByMid, fallbackSender);
3475
+ if (got) {
3476
+ if (Array.isArray(got)) { for (const g of got) items.push(g); }
3477
+ else items.push(got);
3478
+ }
3479
+ if (items.length >= limit) break;
3480
+ }
3481
+ low = hi + 1;
3482
+ }
3483
+ reachedEnd = low > top;
3484
+ } else {
3485
+ let high = beforeSeq !== undefined ? Math.max(0, beforeSeq - 1) : Math.max(0, sess.seq - 1);
3387
3486
  while (high >= 0 && items.length < limit) {
3388
3487
  const low = Math.max(0, high - STEP + 1);
3389
3488
  let evs = [];
@@ -3401,12 +3500,13 @@ export function apply(ctx) {
3401
3500
  if (low === 0) { reachedEnd = true; break; }
3402
3501
  high = low - 1;
3403
3502
  }
3503
+ }
3404
3504
  // ⚠️ 2026-09-10 修复(主人实测"同一批次的历史消息会倒序"):
3405
3505
  // 原为 items.reverse() —— 倒扫时同一打包块([Chat history begins]…[Chat history ends])
3406
3506
  // 拆出的多条共用同一个 ev.seq, 块内本就是旧→新; 整体 reverse 会把"块内顺序"也翻反,
3407
3507
  // 表现为"整批之间顺序对、批内倒序"。
3408
3508
  // 改为: 按 seq 分块 → 块间倒序(旧→新), 块内保持原顺序。
3409
- {
3509
+ if (afterSeq === undefined) {
3410
3510
  const blocks = [];
3411
3511
  for (const it of items) {
3412
3512
  const last = blocks[blocks.length - 1];