dsh-qqbot-guard 0.0.0-stage → 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 anwesc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,226 @@
1
- # Temporary Holding Version
1
+ # qqbot-guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **qqbot 增强层**(伴侣插件)。qqbot 主体仍由用户从插件市场安装(`@tencent-connect/dsh-qqbot`),
4
+ 本插件只做进程内补丁,不修改 qqbot / SDK 的任何磁盘文件。
5
+
6
+ 本仓库是 `dsh-qqbot-guard` 的**唯一真源**(源码、测试、发布都在这里)。
7
+ 两种来源,包名相同:从插件市场 / npm 单独安装(`dsh-qqbot-guard`),
8
+ 或随 [dsh-android](https://github.com/anwesc/dsh-android) APK 内置(构建时从 npm 拉同一版本)。
9
+
10
+ ## 它补什么
11
+
12
+ | 编号 | 现象 | 本插件的处理 |
13
+ | --- | --- | --- |
14
+ | 1 | 群聊必须 @ 才回,关键词(如 `YONGJIE`)被 `mentionGate` 拦下 | 正文命中关键词时给 `msg.mentions` 补 `{is_you:true}`,`mentionGate` / `slashCommand` 都判成被提及 |
15
+ | 2 | 关键词触发时看不到上下文(`inbound.js:209` 只在 `wasMentioned` 时拼历史) | 同上:`wasMentioned` 变 true → `buildAgentBody` 自动带上 `[Chat history]` |
16
+ | 3 | 群历史里只剩文字,图片/语音/文件的条目是空行(`history-buffer.js:90-96` 只存 `content`) | 在链头**预下载**附件(`downloadMediaAttachments`),再包装 history store 的 `list`,给条目补 `[图片「a.png」@/…/media/a_…png、语音]` |
17
+ | 4 | 表情是占位符 `<faceType=6,faceId="0",ext="eyJ0ZXh0IjoiIn0=">`,上游只读 `ext.text`(是空的) | 解码 `ext.text` 有名字就用名字(如 `捂脸`);为空则统一给 `[表情]`,**不按 `faceId` 猜**(见下方第 5 条风险) |
18
+ | 5 | 群里没触发关键词的消息,附件根本不会下载(`attachmentProcessor` 是链上第 13 层,在 `mentionGate` 后面) | guard 自己在链头调 qqbot 的下载器,历史条目因此能带路径 |
19
+ | 6 | 不想动 qqbot 的 `dist` 文件(升级即失效) | 全部走进程内原型补丁,升级 qqbot 不影响本插件文件 |
20
+
21
+ **为什么改成伪造 mentions 而不是往日文里塞 `<@appId>`**:`historyBuffer` 是第 4 层、在门控(第 5 层)
22
+ 之前记录 `ctx.message.content`,往里插标记等于把 `<@102000000>` 写进群历史,再被 `buildAgentBody`
23
+ 原样拼进 prompt。伪造 `mentions[].is_you` 能达到同样效果且不碰正文;真实 @ 的标记同理,会被搬进
24
+ mentions 后从正文摘掉。
25
+
26
+ ## 为什么和加载顺序无关
27
+
28
+ `@tencent-connect/qqbot-nodejs` 的 `handleInboundMessage` 在**每次收到消息时**才拼链
29
+ (`dist/QQBot.js:387` `const chain = [...this.middlewares, downstream]`),而 `middlewares` 是公开数组
30
+ (`dist/QQBot.js:80`)。所以本插件不用抢 `bot.use` 的时机,而是在首条入站消息时
31
+ `this.middlewares.unshift(guard)` —— 即使 qqbot 插件先启动、链已经建好,补丁照样生效。
32
+
33
+ ## 安装
34
+
35
+ 两种来源写的是同一个目录 `node_modules/dsh-qqbot-guard`,装其中一个即可:
36
+
37
+ | 来源 | 怎么装 | 谁负责升级 |
38
+ | --- | --- | --- |
39
+ | 插件市场 / npm | 市场里搜 `dsh-qqbot-guard`,或 `dsh plugin --profile web add dsh-qqbot-guard` | 你自己(市场安装的版本优先) |
40
+ | dsh-android 内置 | 随 APK 的 `plugin-seed` 自动复制进 profile | App 发新版时顺带升级 |
41
+
42
+ `PluginSeed` 每次开机比较「内置版本」与「profile 已装版本」(semver):已装版本有效且不比内置旧就
43
+ **保留不覆盖**;只有缺 `index.js`、没有 `package.json`、版本号非法、或内置版本更新时才用资产覆盖。
44
+ 所以市场装的新版能活过重启,APK 带更新的内置版时也能自动升级。
45
+
46
+ 反过来,从市场卸载不会让它消失:下次启动内置副本会补回(这是「内置插件」的预期行为)。
47
+ 不想让它工作就设 `enabled: false`,而不是卸载。
48
+
49
+ 1. 先装好 qqbot(`@tencent-connect/dsh-qqbot`),本插件靠它才能拿到 bot 实例;
50
+ 2. 装本插件:市场安装,或 `dsh plugin --profile web add dsh-qqbot-guard`;
51
+ 3. 本包自带 `dsh.bundle.patch`(`cordis.patch.yml`,用 `- insert:` 插入 `qqbot-guard` 行),
52
+ 被列入 `dsh.profile.bundles` 后会自动生效——**不要**在 profile 的 `cordis.patch.yml` 里再写一条同 id 的新行;
53
+ 4. 需要改参数时,二选一:
54
+ - **JSON 后门(手机端推荐)**:编辑 `$DSH_HOME/qqbot-guard.json`
55
+ (App 里是 `/root/.dsh/qqbot-guard.json`,首次启动由 `PluginSeed` 生成);
56
+ - **profile patch 覆盖**(能写 YAML 时):
57
+
58
+ ```yaml
59
+ - id: qqbot-guard
60
+ name: 'dsh-qqbot-guard'
61
+ config:
62
+ triggers:
63
+ - '大肥鱼'
64
+ - '小鱼'
65
+ - '鱼鱼'
66
+ qqbotPackage: 'C:/Users/<you>/.dsh/profiles/<profile>/node_modules/@tencent-connect/dsh-qqbot'
67
+ verbose: true
68
+ ```
69
+
70
+ 5. **重启 App**(bundles 变更不走热重载)。
71
+
72
+ > 关键机制(`@deepseek-ai/dsh-app-boot` 的 `applyEntryPatches`):普通 patch 只能改**已存在**的 id,
73
+ > 找不到就 `warn` 并跳过;新增插件行只能用 `insert`。所以「只写一条 `- id: xxx` 到 profile patch」
74
+ > 会静默无效——踩过这个坑。
75
+
76
+ `qqbotPackage` 一般**不用写**:插件会按下面的顺序自动定位 qqbot(命中即停):
77
+
78
+ | 顺序 | 位置 | 说明 |
79
+ | --- | --- | --- |
80
+ | 1 | `config.qqbotPackage` / `DSH_QQBOT_DIR` | 显式指定优先,用于非标准布局 |
81
+ | 2 | 本插件自己所在的 `node_modules` | profile 布局:`<home>/profiles/<name>/node_modules/…`,**与 qqbot 是同一个目录** |
82
+ | 3 | `<home>/profiles/*/node_modules` | 依次枚举各 profile;另含 pnpm 的 `.pnpm` store |
83
+ | 4 | `<home>/node_modules`、`<home>/plugins/*/node_modules` | 插件市场早期的平铺布局 |
84
+ | 5 | 从 `cwd` 按 Node 规则向上找 | 裸部署/仓库内调试 |
85
+
86
+ `<home>` 依次取 `$DSH_HOME`、**从本插件的安装路径反推**(`…/profiles/<name>/node_modules/…` → `…`)、`~/.dsh`。
87
+ 第 2 条是这次修掉的关键:以前候选列表里没有 `profiles/<name>/node_modules` 这一层,手机端(qqbot 由插件市场装进 profile)
88
+ 每次都「没找到」然后**在第一步直接 return**,关键词放行、原型补丁、历史附件全部静默失效。
89
+
90
+ 只有真的找不到时才需要写死路径:
91
+
92
+ ```yaml
93
+ - id: qqbot-guard
94
+ name: 'dsh-qqbot-guard'
95
+ config:
96
+ qqbotPackage: 'C:/Users/<you>/.dsh/profiles/<profile>/node_modules/@tencent-connect/dsh-qqbot'
97
+ ```
98
+
99
+ 找不到时的日志会列出**试过的每一处**安装路径(而不是只报一句「没找到」),可直接照着补 `qqbotPackage`。
100
+
101
+ 插件声明 `inject: ['agents']`,与 qqbot 插件同级,谁先启动都行。
102
+
103
+ > APK 内置时不写 `dependencies`:`PluginSeed` 只把目录拷进 `node_modules` 并登记
104
+ > `dsh.profile.bundles`。`file:` 依赖会绑死容器路径,而 loader 按名字解析,够用。
105
+
106
+ ### 配置项
107
+
108
+ | 字段 | 默认值 | 说明 |
109
+ | --- | --- | --- |
110
+ | `enabled` | `true` | 总开关;`false` 时插件什么都不做,qqbot 行为保持原样 |
111
+ | `triggers` | `['大肥鱼', '肥鱼', '鱼鱼']` | 关键词列表,正文包含任一词即视为对我说话 |
112
+ | `ignoreCase` | `true` | 关键词是否忽略大小写 |
113
+ | `qqbotPackage` | `''` | dsh-qqbot 安装目录或它的 package.json;留空则自动探测(本插件同 profile 的 `node_modules` → `DSH_HOME/profiles/*` → `DSH_HOME` → `cwd` 向上) |
114
+ | `configFile` | `''` | 外部 JSON 配置路径;留空时依次取 `$DSH_QQBOT_GUARD_CONFIG` → `$DSH_HOME/qqbot-guard.json` → `~/.dsh/qqbot-guard.json` |
115
+ | `faces` | `true` | 把 `<faceType=…>` / `[<face,id=N/>]` 还原成 emoji;关掉则保持上游原样(上游会直接删标签) |
116
+ | `historyAttachments` | `true` | 往群历史条目里补附件摘要(`[图片「a.png」]`);关掉则历史里只剩文字 |
117
+ | `strictVersion` | `false` | SDK 版本超出白名单 `>=1.0.5 <2.0.0` 时是否放弃安装 |
118
+ | `verbose` | `true` | 是否打印诊断日志,就绪日志会带上「配置源=…」 |
119
+
120
+ 配置优先级:`DEFAULT_CONFIG` < 外部 JSON 文件 < cordis 里的 `config`。JSON 解析失败只会打一条
121
+ warn 然后忽略,不会让插件挂掉——手机上手改坏了不会变砖。
122
+
123
+ ## 自检
124
+
125
+ ```bash
126
+ node --test # 定位逻辑 + apply() 端到端回归(不需要真的装 qqbot)
127
+ DSH_QQBOT_DIR=/path/to/node_modules/@tencent-connect/dsh-qqbot node selfcheck.mjs
128
+ ```
129
+
130
+ `discovery.test.mjs` 在 tmp 里造出设备上的真实布局(guard 与 qqbot 同在
131
+ `profiles/web/node_modules`、`DSH_HOME` 未设、cwd 为空的 `work` 目录),断言能定位到包;
132
+ `boot.test.mjs` 用假 qqbot 跑一遍 `apply()`,断言不再在第一步退出、且群消息关键词能补上
133
+ `mentions.is_you`。这两个用例在旧实现上都会失败(失败点正是「没找到 @tencent-connect/dsh-qqbot」)。
134
+
135
+ 自检会:先按 qqbot 原样建 bot(补丁前)→ 装补丁 + 包装 history store → 喂五条消息
136
+ (闲聊 / 关键词 / 真 @ / 表情占位符 / 纯图片),断言链头部是 guard、关键词消息被处理、
137
+ `wasMentioned=true`、正文无 `<@` 标记、闲聊消息仍被拦、群历史已累积、历史条目无 `<@` 残留、
138
+ 空 ext 的表情变 `[表情]`、命名表情解码成名字、老格式表情仍查表、历史里有附件摘要。
139
+
140
+ ### 实测结果(qqbot 0.5.3 + qqbot-nodejs 1.0.5,Node 24.21.0)
141
+
142
+ ```
143
+ qqbot=0.5.3 sdk=1.0.5 chain=14
144
+ PASS 补丁已安装
145
+ PASS 链头部是 guard
146
+ PASS 补丁未改变原链长度(懒注入时机正确)
147
+ PASS 关键词消息被处理
148
+ PASS 关键词消息 wasMentioned=true
149
+ PASS 模型收到的是干净文本(无 <@ 标记)
150
+ PASS 无关键词的消息被门控拦下
151
+ PASS 群历史已累积
152
+ PASS history store 已包装(附件摘要生效)
153
+ PASS 历史条目无 <@ 残留
154
+ PASS 空 ext 的表情标签变成 [表情](历史里)
155
+ PASS 空 ext 不猜 faceId 对照表
156
+ PASS 命名表情解码成名字(264 → 捂脸)
157
+ PASS 老格式 [<face,id=1/>] 仍查表
158
+ PASS 历史里带附件摘要
159
+ PASS 历史里补上附件真实路径
160
+ PASS 附件路径是 POSIX 风格绝对路径
161
+ PASS 附件在 guard 里就预下载(不等 mentionGate)
162
+ PASS 预下载路径直接进历史(不靠 glob 兜底)
163
+ PASS 没有本地文件时不硬写路径
164
+ PASS 表情标签不再以占位符形式出现
165
+ ```
166
+
167
+ 全量模式(`requireMention: false`,群消息全收)下同样有效:history 为
168
+ `["闲聊一句","YONGJIE 关键词","真 @ 一句"," [图片「a.png」]"]`,关键词消息被标成 `wasMentioned=true`。
169
+ 注意全量模式本来就全放行,这里的差别只是「标记成在跟我说话」——模型会看到 `(@you)` 并拿到群历史。
170
+
171
+ ## 必须配套的一项配置
172
+
173
+ 群聊要「只有 @ 或命中关键词才回」,**必须**把 qqbot 的 `requireMention` 设为 `true`
174
+ (`cordis.patch.yml` 里 `im-qqbot` 条目):
175
+
176
+ ```yaml
177
+ - id: im-qqbot
178
+ config:
179
+ requireMention: true
180
+ ```
181
+
182
+ 原因:本插件的做法是把命中关键词的消息「伪装成 @」再交给下游 `mentionGate`;若 `requireMention: false`,
183
+ 门控对所有群消息一律放行,插件就只剩「补标记」作用,群里每句话都会触发回复。
184
+
185
+ 未 @ 的消息不会丢上下文:`dsh-qqbot/dist/gateway/middleware-setup.js:29` 把 `historyBuffer` 放在
186
+ `mentionGate`(`:41`)**之前**,且带 `recordOnSkip: true`,被门控拦下的消息照样计入群历史。
187
+
188
+ ## 已知风险
189
+
190
+ 1. **模块实例一致性**:补丁打在 `@tencent-connect/qqbot-nodejs` 的 `QQBot.prototype` 上,必须和 qqbot 解析到
191
+ 同一个文件。本插件按 Node 的 `node_modules` 向上查找规则、从 qqbot 安装目录出发定位 SDK,正常情况下即同一实例;
192
+ 若装了多份 SDK 且版本不同,可能出现补丁打在另一份上而静默失效(此时 `/消息` 行为与未装插件一致)。
193
+ 2. **`node_modules` 解析**:`qqbot-nodejs` 的 `exports` 只有 `import` 条件,`createRequire().resolve()` 在 Node 24 会抛
194
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`(实测)。本插件因此手工读 package.json 的 `exports['.']`,不依赖该 API。
195
+ 3. **历史带标记(已解决,留档)**:早期版本往正文前置 `<@appId>` 来伪造提及,导致群历史条目里带
196
+ `<@102000000> …` 并被原样拼进 prompt。现在改为伪造 `mentions[].is_you`,并把真实 @ 的标记搬进
197
+ mentions 后从正文摘掉,历史是干净原文。代价是多改一个字段(`msg.mentions`),见「已知风险 1」同款顾虑。
198
+ 4. **附件会被下载两次(仅触发消息)**:关键词触发的消息里若有附件,guard 先下一次,随后
199
+ `attachmentProcessor` 又下一次——media 目录里会多一个同内容的随机名副本。这是为了“没触发的消息也能在
200
+ 历史里点开”付出的代价;`historyMedia: false` 可以关掉预下载(回到只有文件名)。
201
+ 5. **历史里的附件路径只在 24 小时内有效**:上游把附件下载到 `~/.dsh-qqbot/media`,文件名是
202
+ `<原名>_<时间戳>_<随机><ext>`(`attachment.js:60` `uniqueFilename`),后缀随机,**落库时算不出来**,
203
+ 而且下载发生在中间件链之后——所以本插件改成在读历史时按 `<原名>_*` 前缀匹配、取 mtime 最新的那个来补路径
204
+ (`resolveMediaPath`)。代价与边界:
205
+ - 媒体默认 24 小时过期(`config.media.ttlHours` 默认 24,`0`=永不过期,每小时扫一次),过期后历史里的
206
+ 路径点开就是空的——路径只当“可能还在”的线索,别当保证;
207
+ - 只保留最近 200 条带附件的消息摘要(`notes` 上限),更早的条目退化成纯文字;
208
+ - 文件名重复(同名文件在同一次会话里发多次)时取最新的那个,可能张冠李戴。
209
+ 本插件不复制、不移动用户的媒体文件。
210
+ 6. **表情 vs 收藏表情包(实测结论)**:`faceId` 是系统表情编号,`ext.text` 才是语义来源——
211
+ 系统表情会带名字(如 `faceId=264` → `{"text":"捂脸"}`)。**收藏的表情包不走 faceId**:QQ 把它作为
212
+ 图片附件下发(落到 `~/.dsh-qqbot/media/`),同时附一个空文本的 face 标签(`faceType=6,faceId="0"`)。
213
+ 所以本插件对空 `ext.text` 只给 `[表情]`,不再按 faceId 表猜 emoji(早期版本把 0 映射成 😊 是错的)。
214
+ 若想换措辞改 `normalizeFaces` 的兜底值。
215
+ 7. **版本漂移**:只用 qqbot/SDK 的四个公开面 —— `middlewares`、`handleInboundMessage`、`ctx.bot.appId`、
216
+ `ctx.state.mention`,外加 `history-store` 的 `getHistoryStore()`(拿不到就只打 warn,附件摘要自动关)。
217
+ 白名单外的版本仍会尝试安装并打 warn,出问题先跑 `selfcheck.mjs`。
218
+
219
+ ## 边界
220
+
221
+ - 不改 qqbot 源码,不写 `node_modules`,不碰 App 的 Java 层。
222
+ - 媒体缓存、脚本、手册等其余增强项不在本插件范围内(另行安排)。
223
+
224
+ ## License
225
+
226
+ MIT,见 [LICENSE](LICENSE)。Copyright (c) 2026 anwesc。
@@ -0,0 +1,22 @@
1
+ # qqbot-guard bundle patch
2
+ #
3
+ # 本包被列入 profile 的 dsh.profile.bundles 后,dsh 启动时自动应用此 patch,
4
+ # 把下面这一行插进插件树。真正的门禁参数请在 profile 的 cordis.patch.yml 里覆盖
5
+ # (同 id 的普通 patch 会覆盖这里的默认值)。
6
+ #
7
+ # 注意:profile 的 cordis.patch.yml 只能改「已存在的 id」,新增插件行必须写在
8
+ # bundle patch(本文件)里,或者用 `- insert:` 形式手动插入。
9
+
10
+ - insert:
11
+ - id: qqbot-guard
12
+ name: 'dsh-qqbot-guard'
13
+ config:
14
+ # triggers 故意不写:由 $DSH_HOME/qqbot-guard.json 或 DEFAULT_CONFIG(大肥鱼/肥鱼/鱼鱼)决定,
15
+ # 手机端改关键词不用碰 YAML。要在这里写死也行,会盖过 JSON 文件。
16
+ ignoreCase: true
17
+ # 留空则自动探测:先看本插件自己所在的 node_modules(也就是 profile 布局
18
+ # <home>/profiles/<name>/node_modules),再看 <home>/profiles/*、<home>/plugins/*,
19
+ # 最后从 cwd 按 Node 规则向上找。只有自动探测落空时才需要在这里写死路径。
20
+ qqbotPackage: ''
21
+ strictVersion: false
22
+ verbose: true
package/index.js ADDED
@@ -0,0 +1,874 @@
1
+ /**
2
+ * qqbot-guard — dsh-android 内置的 qqbot 增强层(伴侣插件)。
3
+ *
4
+ * 目标:qqbot 主体仍由用户从插件市场安装,本插件只补三件事——
5
+ * 1) 群聊关键词放行(不 @ 也能触发);
6
+ * 2) 关键词触发时把历史上下文带进本轮(qqbot 原逻辑只有 wasMentioned 才拼历史);
7
+ * 3) 全程不改 qqbot / SDK 的磁盘文件,只做进程内补丁。
8
+ *
9
+ * 做法(已在本仓库 tools 的探针里验证,见 plugins/qqbot-guard/README.md):
10
+ * - 不抢 `bot.use` 的调用时机,改为懒注入:
11
+ * 重写 `QQBot.prototype.handleInboundMessage`,首条入站消息时才把 guard
12
+ * `unshift` 到 `this.middlewares` 头部,因此**与插件加载顺序无关**。
13
+ * - guard 命中关键词时给 `msg.mentions` 补一条 `{ is_you: true }`,让 mentionGate
14
+ * 自己判定 `wasMentioned=true`,从而触发 inbound 的历史拼接。**不改正文**:
15
+ * historyBuffer 在门控之前记录 content,早年往正文里插 `<@appId>` 的做法会把标记
16
+ * 带进历史条目,模型在 `[Chat history begins]` 里看到 `<@102000000> …`。
17
+ * slashCommand 也认 `mentions[].is_you`,所以斜杠命令照旧可用。
18
+ * - 顺带把正文里的两类“机器标记”换成可读文本(只改 content,不改附件本体):
19
+ * 新版表情标签 `<faceType=…,faceId="N",ext="…">` → emoji 或 `[表情]`;
20
+ * 群历史里只存 content,附件会整条消失,所以在读历史时补一句附件摘要
21
+ * (含本地路径——落盘名带随机后缀,只能读的时候按前缀去 media 目录里找)。
22
+ */
23
+ import { pathToFileURL, fileURLToPath } from 'node:url';
24
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { dirname, join } from 'node:path';
27
+
28
+ export const name = 'qqbot-guard';
29
+ // 与 qqbot 插件保持一致的最小依赖,保证它排在 agents 之后激活即可。
30
+ export const inject = ['agents'];
31
+
32
+ const SDK_PKG = '@tencent-connect/qqbot-nodejs';
33
+ const QQBOT_PKG = '@tencent-connect/dsh-qqbot';
34
+ const SDK_MIN = [1, 0, 5];
35
+ const SDK_MAX_EXCLUSIVE = [2, 0, 0];
36
+ const PATCH_FLAG = Symbol.for('qqbot-guard.handleInboundMessage');
37
+
38
+ export const DEFAULT_CONFIG = {
39
+ /** 总开关:false 时插件什么都不做(纯直通,不改 qqbot 的任何行为) */
40
+ enabled: true,
41
+ /** 关键词放行:正文包含任一词即视为对我说话(不 @ 也可) */
42
+ triggers: ['大肥鱼', '肥鱼', '鱼鱼'],
43
+ /** 关键词匹配是否忽略大小写 */
44
+ ignoreCase: true,
45
+ /**
46
+ * dsh-qqbot 安装目录或 package.json 路径;留空则自动探测。
47
+ * 探测顺序见 findQqbotPackage():本插件同 profile 的 node_modules → 各 dsh home 下的
48
+ * profiles/<name>/node_modules(含 pnpm 的 .pnpm store)→ 从 cwd 按 Node 规则向上找。
49
+ */
50
+ qqbotPackage: '',
51
+ /**
52
+ * 外部 JSON 配置(给不便于改 cordis.patch.yml 的场景,例如手机端)。
53
+ * 留空则依次尝试 DSH_QQBOT_GUARD_CONFIG、$DSH_HOME/qqbot-guard.json、~/.dsh/qqbot-guard.json。
54
+ * 优先级:这里的 cordis 配置 > JSON 文件 > DEFAULT_CONFIG。
55
+ */
56
+ configFile: '',
57
+ /** 把新版表情标签 `<faceType=…>` 换成 emoji(映射不到时给 `[表情]`),默认开 */
58
+ faces: true,
59
+ /** 群历史只存 content,附件会丢;读历史时补一句 `[图片「x.png」@/…]`,默认开 */
60
+ historyAttachments: true,
61
+ /**
62
+ * 历史附件要不要顺手落盘,默认开。
63
+ * qqbot 的 `attachmentProcessor` 排在 mentionGate 之后,没触发关键词的群消息走不到它,
64
+ * 附件就永远不会下载——历史里只剩文件名、点开是空的。
65
+ */
66
+ historyMedia: true,
67
+ /** 预下载的体积上限(MB),语义同 qqbot 的 media.maxMB */
68
+ historyMediaMaxMB: 20,
69
+ /** SDK 版本超出白名单时是否仍然安装(默认只告警) */
70
+ strictVersion: false,
71
+ /** 是否输出诊断日志 */
72
+ verbose: true,
73
+ };
74
+
75
+ // ────────────────────────── 工具 ──────────────────────────
76
+
77
+ function pickLogger(ctx) {
78
+ // cordis 的 ctx.logger 是函数(`ctx.logger('name')` 返回 logger),
79
+ // 但测试与旧版本也可能直接给一个带 info/warn 的对象,两种都接。
80
+ let l = ctx?.logger ?? console;
81
+ if (typeof l === 'function') {
82
+ try {
83
+ l = l('qqbot-guard') ?? console;
84
+ } catch {
85
+ l = console;
86
+ }
87
+ }
88
+ const emit = (level, fallback, m) => {
89
+ const fn = l?.[level];
90
+ if (typeof fn === 'function') return fn.call(l, m);
91
+ return fallback(m);
92
+ };
93
+ return {
94
+ info: (m) => emit('info', console.log, m),
95
+ warn: (m) => emit('warn', console.warn, m),
96
+ error: (m) => emit('error', console.error, m),
97
+ debug: (m) => emit('debug', () => {}, m),
98
+ };
99
+ }
100
+
101
+ // ────────────────────────── 外部配置 ──────────────────────────
102
+
103
+ /**
104
+ * 解析外部 JSON 配置文件的路径。
105
+ * 手机端改 cordis.patch.yml 不方便,所以留一个 JSON 后门。
106
+ */
107
+ export function resolveConfigFile(config = DEFAULT_CONFIG) {
108
+ const explicit = typeof config?.configFile === 'string' ? config.configFile.trim() : '';
109
+ if (explicit) return explicit;
110
+ const env = process.env.DSH_QQBOT_GUARD_CONFIG;
111
+ if (env) return env;
112
+ const home = process.env.DSH_HOME;
113
+ if (home) return join(home, 'qqbot-guard.json');
114
+ try {
115
+ return join(homedir(), '.dsh', 'qqbot-guard.json');
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /** 读取外部 JSON 配置;文件不存在或内容损坏时返回 null,绝不抛出。 */
122
+ export function readConfigFile(config = DEFAULT_CONFIG, logger = console) {
123
+ const file = resolveConfigFile(config);
124
+ if (!file || !existsSync(file)) return null;
125
+ try {
126
+ const data = JSON.parse(readFileSync(file, 'utf8'));
127
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
128
+ throw new Error('顶层必须是 JSON 对象');
129
+ }
130
+ logger.debug?.(`[qqbot-guard] 已读取外部配置 ${file}`);
131
+ return data;
132
+ } catch (err) {
133
+ logger.warn?.(`[qqbot-guard] 读取配置 ${file} 失败(忽略): ${err?.message ?? err}`);
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /** 合并优先级:DEFAULT_CONFIG < 外部 JSON < cordis 里的 config。 */
139
+ export function mergeConfig(config = {}, logger = console) {
140
+ const explicit = config ?? {};
141
+ const file = readConfigFile(explicit, logger) ?? {};
142
+ return { ...DEFAULT_CONFIG, ...file, ...explicit };
143
+ }
144
+
145
+ function parseVersion(v) { const m = String(v ?? '').trim().match(/^(\d+)\.(\d+)\.(\d+)/);
146
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
147
+ }
148
+
149
+ function cmp(a, b) {
150
+ for (let i = 0; i < 3; i += 1) {
151
+ if (a[i] !== b[i]) return a[i] - b[i];
152
+ }
153
+ return 0;
154
+ }
155
+
156
+ function readPkg(dir) {
157
+ const file = existsSync(join(dir, 'package.json')) ? join(dir, 'package.json') : dir;
158
+ if (!existsSync(file)) return null;
159
+ try {
160
+ const pkg = JSON.parse(readFileSync(file, 'utf8'));
161
+ return { pkg, dir: dirname(file), file };
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /** 本文件自己的路径;用来反推 dsh 的目录布局(profile 布局下插件与 qqbot 同处一个 node_modules)。 */
168
+ const SELF_FILE = (() => {
169
+ try {
170
+ return fileURLToPath(import.meta.url);
171
+ } catch {
172
+ return '';
173
+ }
174
+ })();
175
+
176
+ /** 统一成正斜杠再比较:设备上是 Linux,但桌面端调试时路径是 `D:\…`。 */
177
+ function toPosix(p) {
178
+ return String(p ?? '').replace(/\\/g, '/');
179
+ }
180
+
181
+ function safeHomeDsh() {
182
+ try {
183
+ return join(homedir(), '.dsh');
184
+ } catch {
185
+ return '';
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 从本插件的安装位置反推目录布局。
191
+ *
192
+ * 按 profile 组织时本文件在
193
+ * `<home>/profiles/<name>/node_modules/dsh-qqbot-guard/index.js`,
194
+ * 于是能直接拿到 `<home>` 和 profile 目录;qqbot 就装在同一个 node_modules 里。
195
+ *
196
+ * @param {string} [selfFile] 本插件入口文件;默认取 import.meta.url
197
+ * @returns {{home: string, profile: string, profileDir: string, dir: string}}
198
+ */
199
+ export function locateSelfLayout(selfFile = SELF_FILE) {
200
+ const dir = selfFile ? dirname(selfFile) : '';
201
+ const m = toPosix(dir).match(/^(.*)\/profiles\/([^/]+)\/node_modules(\/|$)/);
202
+ if (!m) return { home: '', profile: '', profileDir: '', dir };
203
+ return { home: m[1], profile: m[2], profileDir: `${m[1]}/profiles/${m[2]}`, dir };
204
+ }
205
+
206
+ /**
207
+ * 按 Node 的 node_modules 向上查找规则列出候选路径。
208
+ *
209
+ * 从 startDir 起每一级看 `<dir>/node_modules/<pkg>`;如果这一级本身就叫 node_modules,
210
+ * 直接看 `<dir>/<pkg>`(否则会拼出 `node_modules/node_modules/…` 这种永远不存在的路径)。
211
+ *
212
+ * @param {string} startDir 起点目录
213
+ * @param {string} pkg 包名(可带 scope)
214
+ * @param {string[]} [stopDirs] 走到这些目录就停(含),避免一路走到 `/` 把诊断列表刷满
215
+ * @returns {string[]} 候选安装目录,由近到远
216
+ */
217
+ function nodeLookupCandidates(startDir, pkg, stopDirs = []) {
218
+ const stops = new Set((stopDirs ?? []).filter(Boolean).map((d) => toPosix(d).replace(/\/+$/, '')));
219
+ const out = [];
220
+ let dir = startDir;
221
+ while (dir) {
222
+ const base = toPosix(dir).replace(/\/+$/, '').split('/').pop();
223
+ out.push(base === 'node_modules' ? join(dir, pkg) : join(dir, 'node_modules', pkg));
224
+ if (stops.has(toPosix(dir).replace(/\/+$/, ''))) break;
225
+ const parent = dirname(dir);
226
+ if (!parent || parent === dir) break;
227
+ dir = parent;
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /**
233
+ * pnpm 布局:`<node_modules>/.pnpm/@tencent-connect+dsh-qqbot@<ver>/node_modules/<pkg>`。
234
+ * 顶层软链被 prune 掉时(或 symlink 未展开时)还能靠 store 找到。
235
+ *
236
+ * @param {string} nodeModules node_modules 目录
237
+ * @param {string} pkg 包名
238
+ * @returns {string[]} 候选安装目录
239
+ */
240
+ function pnpmCandidates(nodeModules, pkg) {
241
+ const out = [];
242
+ const store = join(nodeModules, '.pnpm');
243
+ const prefix = `${pkg.replace('/', '+')}@`;
244
+ try {
245
+ for (const entry of readdirSync(store, { withFileTypes: true })) {
246
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
247
+ out.push(join(store, entry.name, 'node_modules', pkg));
248
+ }
249
+ } catch {
250
+ /* 没有 .pnpm 目录 */
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * 列出 qqbot 的所有候选安装目录(按优先级,已去重)。
257
+ *
258
+ * 顺序:显式配置 → DSH_QQBOT_DIR → 本插件所在位置(Node 向上查找 + 同 profile 的 pnpm store)
259
+ * → 各 dsh home 下的 node_modules / plugins/* / profiles/* → cwd 向上查找。
260
+ *
261
+ * @param {object} config 插件配置
262
+ * @param {{selfFile?: string, cwd?: string, fallbackHome?: string}} [opts] 测试用注入点
263
+ * @returns {string[]} 候选目录
264
+ */
265
+ export function qqbotCandidates(config = DEFAULT_CONFIG, opts = {}) {
266
+ const selfFile = opts.selfFile ?? SELF_FILE;
267
+ const cwd = opts.cwd ?? process.cwd();
268
+ const candidates = [];
269
+ const seen = new Set();
270
+ const push = (...paths) => {
271
+ for (const p of paths.flat()) {
272
+ if (!p || typeof p !== 'string') continue;
273
+ const key = toPosix(p);
274
+ if (seen.has(key)) continue;
275
+ seen.add(key);
276
+ candidates.push(p);
277
+ }
278
+ };
279
+
280
+ // 1. 显式配置 > 环境变量
281
+ push(config?.qqbotPackage);
282
+ push(process.env.DSH_QQBOT_DIR);
283
+
284
+ const layout = locateSelfLayout(selfFile);
285
+ const homes = [];
286
+ const addHome = (h) => {
287
+ if (!h) return;
288
+ if (homes.some((x) => toPosix(x) === toPosix(h))) return;
289
+ homes.push(h);
290
+ };
291
+ addHome(process.env.DSH_HOME);
292
+ addHome(layout.home);
293
+ addHome(opts.fallbackHome !== undefined ? opts.fallbackHome : safeHomeDsh());
294
+
295
+ // 2. 本插件自己的位置:profile 布局(<home>/profiles/<name>/node_modules)走这条
296
+ if (selfFile) push(nodeLookupCandidates(dirname(selfFile), QQBOT_PKG, homes));
297
+ if (layout.profileDir) push(pnpmCandidates(join(layout.profileDir, 'node_modules'), QQBOT_PKG));
298
+
299
+ // 3. 各 dsh home 下的常见布局
300
+ for (const home of homes) {
301
+ push(join(home, 'node_modules', QQBOT_PKG));
302
+ push(pnpmCandidates(join(home, 'node_modules'), QQBOT_PKG));
303
+ // 插件市场早期的平铺布局
304
+ push(join(home, 'plugins', 'node_modules', QQBOT_PKG));
305
+ const plugins = join(home, 'plugins');
306
+ if (existsSync(plugins)) {
307
+ try {
308
+ for (const entry of readdirSync(plugins, { withFileTypes: true })) {
309
+ if (!entry.isDirectory()) continue;
310
+ const nm = join(plugins, entry.name, 'node_modules');
311
+ push(join(nm, QQBOT_PKG));
312
+ push(pnpmCandidates(nm, QQBOT_PKG));
313
+ }
314
+ } catch {
315
+ /* 读不到就跳过 */
316
+ }
317
+ }
318
+ // 按 profile 组织:<home>/profiles/<name>/node_modules(插件市场的实际落盘位置)
319
+ const profiles = join(home, 'profiles');
320
+ if (existsSync(profiles)) {
321
+ try {
322
+ for (const entry of readdirSync(profiles, { withFileTypes: true })) {
323
+ if (!entry.isDirectory()) continue;
324
+ const nm = join(profiles, entry.name, 'node_modules');
325
+ push(join(nm, QQBOT_PKG));
326
+ push(pnpmCandidates(nm, QQBOT_PKG));
327
+ }
328
+ } catch {
329
+ /* 读不到就跳过 */
330
+ }
331
+ }
332
+ }
333
+
334
+ // 4. cwd 向上(非 profile 的裸部署,保留旧行为)
335
+ push(nodeLookupCandidates(cwd, QQBOT_PKG));
336
+
337
+ return candidates;
338
+ }
339
+
340
+ /**
341
+ * 探测 dsh-qqbot 的安装目录(也就是 qqbot 插件本体)。
342
+ *
343
+ * 找不到时不会抛错,但会在日志里列出**试过的每一处**——以前只打一句「没找到」,
344
+ * 于是插件在第一步静默退出,花很久才定位到「候选列表缺 profiles/<name>/node_modules」。
345
+ *
346
+ * @param {object} config 插件配置
347
+ * @param {object} logger 日志
348
+ * @param {{selfFile?: string, cwd?: string, fallbackHome?: string}} [opts] 测试用注入点
349
+ * @returns {{pkg: object, dir: string, file: string} | null}
350
+ */
351
+ export function findQqbotPackage(config = DEFAULT_CONFIG, logger = console, opts = {}) {
352
+ const candidates = qqbotCandidates(config, opts);
353
+ for (const c of candidates) {
354
+ const hit = readPkg(c);
355
+ if (hit?.pkg?.name === QQBOT_PKG) return hit;
356
+ }
357
+
358
+ const layout = locateSelfLayout(opts.selfFile ?? SELF_FILE);
359
+ const shown = candidates.slice(0, 12).map((c) => `\n - ${c}`).join('');
360
+ const rest = candidates.length > 12 ? `\n - …另有 ${candidates.length - 12} 处` : '';
361
+ logger.warn?.(
362
+ `[qqbot-guard] 没找到 ${QQBOT_PKG},补丁不会生效(关键词放行、历史附件都不可用)。` +
363
+ `\n 本插件在 ${layout.dir || '(未知)'},共试了 ${candidates.length} 处:${shown}${rest}` +
364
+ `\n 可用 config.qqbotPackage 或环境变量 DSH_QQBOT_DIR 指定它的安装目录。`,
365
+ );
366
+ return null;
367
+ }
368
+
369
+ /**
370
+ * 定位 SDK 的真实文件,保证和 qqbot 拿到的是同一个模块实例。
371
+ *
372
+ * 不能用 createRequire(...).resolve:qqbot-nodejs 的 exports 只有 "import",
373
+ * CJS 解析器会直接抛 ERR_PACKAGE_PATH_NOT_EXPORTED(实测 Node 24)。
374
+ * 这里按 Node 的 node_modules 向上查找规则手工定位,再读它自己的 exports 映射。
375
+ */
376
+ export function resolveSdk(qqbotDir) {
377
+ const segments = SDK_PKG.split('/');
378
+ let dir = qqbotDir;
379
+ let pkgHit = null;
380
+ while (dir && dir !== dirname(dir)) {
381
+ const candidate = join(dir, 'node_modules', ...segments);
382
+ const hit = readPkg(candidate);
383
+ if (hit?.pkg?.name === SDK_PKG) {
384
+ pkgHit = hit;
385
+ break;
386
+ }
387
+ dir = dirname(dir);
388
+ }
389
+ if (!pkgHit) {
390
+ throw new Error(`从 ${qqbotDir} 向上找不到 ${SDK_PKG} 的安装位置`);
391
+ }
392
+ const exp = pkgHit.pkg.exports?.['.'];
393
+ const target =
394
+ (typeof exp === 'string' ? exp : exp?.import ?? exp?.default ?? exp?.require) ??
395
+ pkgHit.pkg.module ??
396
+ pkgHit.pkg.main ??
397
+ './dist/index.js';
398
+ const entry = join(pkgHit.dir, target);
399
+ if (!existsSync(entry)) {
400
+ throw new Error(`${SDK_PKG} 入口不存在:${entry}`);
401
+ }
402
+ return { entry, pkgFile: pkgHit.file, version: pkgHit.pkg.version, dir: pkgHit.dir };
403
+ }
404
+
405
+ // ────────────────────────── guard 中间件 ──────────────────────────
406
+
407
+ /**
408
+ * QQ 表情 id → emoji。与 `qqbot-nodejs` 的 `content-sanitizer` 用的同一张表:
409
+ * 那张表只在**老格式** `[<face,id=N/>]` 分支里用到,新格式分支只看 `<faceType>` 里的
410
+ * `ext.text`,于是 `ext` 为空(QQ 只下发 `{"text":""}`)时就退化成「未知表情」。
411
+ * 注意:新格式的 `faceId` 与本表的 id 是否同一命名空间,上游没有任何文档或代码证据,
412
+ * 这里按同一套编号猜——猜错只是 emoji 不对,不会丢信息(正文里的 ext 本身就没内容)。
413
+ */
414
+ const FACE_EMOJI = {
415
+ 0: '😊', 1: '😣', 2: '😍', 4: '😎', 5: '😭', 6: '😳', 7: '🤐', 8: '😴',
416
+ 9: '😢', 10: '😰', 11: '😡', 12: '🤗', 13: '⭐', 14: '🌟', 15: '🌙',
417
+ 16: '👍', 18: '✊', 21: '😊', 23: '😢', 25: '🤔', 26: '😱', 27: '😅',
418
+ 28: '😁', 29: '🤮', 30: '💪', 32: '🎉', 33: '😤', 34: '😏', 35: '🥺',
419
+ 49: '🐷', 53: '🎂', 60: '☕', 63: '🌹', 66: '❤️', 74: '🌞', 75: '🌛',
420
+ 76: '👏', 78: '🤝', 79: '✌️', 85: '🎅', 89: '🍉', 96: '😓', 97: '😲',
421
+ 100: '😂', 101: '😊', 104: '😭', 106: '😱', 109: '😘', 111: '🥰',
422
+ };
423
+
424
+ /** `<faceType=6,faceId="0",ext="eyJ0ZXh0IjoiIn0=">` */
425
+ const FACE_TAG_NEW = /<faceType=\d+,faceId="([^"]*)",ext="([^"]*)">/g;
426
+ /** 老格式 `[<face,id=6/>]`;上游 sanitizer 也会处理,这里先手换掉避免历史里留下标签 */
427
+ const FACE_TAG_OLD = /\[<face,id=(\d+)\/?>]/g;
428
+
429
+ /**
430
+ * 把表情标签换成可读文本。
431
+ *
432
+ * 优先用 `ext` 里 QQ 下发的表情名;没有名字就统一给 `[表情]`,
433
+ * 不按 `faceId` 猜 emoji(实测 faceId=0 配空文本 = 收藏表情包,猜出来的语义是错的)。
434
+ * 只做字符串替换,不抛异常(`ext` 是外部数据)。
435
+ *
436
+ * @param {string} text 原始正文
437
+ * @returns {string} 替换后的正文
438
+ */
439
+ export function normalizeFaces(text) {
440
+ if (typeof text !== 'string' || text === '') return text;
441
+ return text
442
+ .replace(FACE_TAG_NEW, (match, _faceId, ext) => {
443
+ let name = '';
444
+ try {
445
+ const decoded = Buffer.from(ext ?? '', 'base64').toString('utf8');
446
+ const parsed = JSON.parse(decoded);
447
+ name = typeof parsed?.text === 'string' ? parsed.text.trim() : '';
448
+ } catch {
449
+ // ext 不是合法 base64/JSON:当作没有名字
450
+ }
451
+ if (name) return name;
452
+ // ext.text 为空:多半是用户收藏的表情包(本体在图片附件里),
453
+ // 实测 faceId=0 配空文本就是这种情况,按 faceId 猜 emoji 会给出错误语义,
454
+ // 所以这里只给一个中性的占位符。
455
+ return '[表情]';
456
+ })
457
+ .replace(FACE_TAG_OLD, (_match, id) => FACE_EMOJI[String(id)] ?? '[表情]');
458
+ }
459
+
460
+ const ATTACHMENT_LABEL = { image: '图片', voice: '语音', video: '视频', file: '文件' };
461
+
462
+ /**
463
+ * 给历史条目拼一句附件摘要。
464
+ *
465
+ * `historyBuffer` 只把 `content` 落库(senderId/senderName/content/timestamp/messageId),
466
+ * 所以纯图片/纯文件的消息在历史里是一条空行。这里补成 `[图片「a.png」]`。
467
+ *
468
+ * @param {Array} attachments qqbot 解析后的附件数组
469
+ * @returns {string} 形如 ` [图片「a.png」、语音]`,无附件时返回空串
470
+ */
471
+ /**
472
+ * 找附件真正落在磁盘上的位置。
473
+ *
474
+ * 上游 `attachment.js` 的落盘名是 `<原名去扩展>_<Date.now()>_<8位hex><ext>`,
475
+ * 后缀带时间戳和随机数,**写历史的时候算不出来**(而且下载本身就晚于历史落库),
476
+ * 所以只能事后按 `<原名>_*` 前缀去 media 目录里匹配,取 mtime 最新的那个。
477
+ *
478
+ * @param {string} mediaRoot 媒体目录(qqbot 的 MEDIA_ROOT,默认 ~/.dsh-qqbot/media)
479
+ * @param {string} filename QQ 下发的原始文件名
480
+ * @returns {string} POSIX 风格绝对路径;找不到返回空串
481
+ */
482
+ function resolveMediaPath(mediaRoot, filename) {
483
+ if (!mediaRoot || typeof filename !== 'string' || filename === '') return '';
484
+ try {
485
+ // 与上游 attachment.js:45 sanitizeFilename 保持一致:只换文件系统非法字符 + NFC
486
+ const base = String(filename).replace(/\\/g, '/').split('/').pop() ?? '';
487
+ const safe = base.replace(/[<>:"|?*\x00-\x1f]/g, '_').trim().normalize('NFC') || 'attachment';
488
+ const dot = safe.lastIndexOf('.');
489
+ const stem = dot > 0 ? safe.slice(0, dot) : safe;
490
+ const ext = dot > 0 ? safe.slice(dot) : '';
491
+ const candidates = readdirSync(mediaRoot).filter(
492
+ (n) => n.startsWith(`${stem}_`) && n.endsWith(ext),
493
+ );
494
+ if (candidates.length === 0) return '';
495
+ let best = '';
496
+ let bestTime = -1;
497
+ for (const n of candidates) {
498
+ const stat = statSync(join(mediaRoot, n));
499
+ if (stat.mtimeMs > bestTime) {
500
+ bestTime = stat.mtimeMs;
501
+ best = n;
502
+ }
503
+ }
504
+ // inbound 注入的路径是 POSIX 风格(renderMediaLine),这里保持一致
505
+ return `${mediaRoot.replace(/\\/g, '/').replace(/\/$/, '')}/${best}`;
506
+ } catch {
507
+ // 目录不存在 / 没权限:不标路径即可,不影响摘要
508
+ return '';
509
+ }
510
+ }
511
+
512
+ /**
513
+ * 把附件列表压成一句摘要,给群历史用。
514
+ * @param {Array} attachments msg.attachments(或存下来的精简副本)
515
+ * @param {string} [mediaRoot] 给了就顺带标注本地路径
516
+ */
517
+ export function summarizeAttachments(attachments, mediaRoot = '') {
518
+ if (!Array.isArray(attachments) || attachments.length === 0) return '';
519
+ const parts = [];
520
+ for (const att of attachments) {
521
+ const label = ATTACHMENT_LABEL[att?.type] ?? '附件';
522
+ const name = att?.filename ?? att?.voiceText ?? '';
523
+ if (!name) {
524
+ parts.push(label);
525
+ continue;
526
+ }
527
+ const raw = att?.localPath || resolveMediaPath(mediaRoot, att?.filename ?? '');
528
+ // 落盘路径统一成 POSIX,跟 inbound 注入的 `- Image:` 保持一致
529
+ const path = raw ? raw.replace(/\\/g, '/') : '';
530
+ parts.push(path ? `${label}「${name}」@${path}` : `${label}「${name}」`);
531
+ }
532
+ return parts.length > 0 ? ` [${parts.join('、')}]` : '';
533
+ }
534
+
535
+ /** 正则字面量转义:appId 是纯数字,但配置里可能塞奇怪东西,别让它变成正则元字符 */
536
+ function escapeRegExp(text) {
537
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
538
+ }
539
+
540
+ /**
541
+ * 给消息补一条“自己 @ 了 bot”的 mention。
542
+ * mentionGate 认 `mentions[].is_you`,slashCommand 也认;historyBuffer 只存正文,所以这样最干净。
543
+ */
544
+ function markMention(msg, extra = {}) {
545
+ const mentions = Array.isArray(msg.mentions) ? msg.mentions : [];
546
+ if (!mentions.some((m) => m?.is_you === true)) {
547
+ mentions.push({ is_you: true, ...extra });
548
+ }
549
+ msg.mentions = mentions;
550
+ }
551
+
552
+ /**
553
+ * 真正的门禁逻辑。挂在中间件链头部,做两件事:
554
+ * 1. 正文里的表情标签换成可读文本(历史与当前消息都受益);
555
+ * 2. 群聊命中关键词时给 `msg.mentions` 补 `{ is_you: true }`,让下游 mentionGate
556
+ * 判成“被 @”,从而放行并拼上群历史。**绝不改 `msg.content`**(历史会被污染)。
557
+ */
558
+ export function createGuard(
559
+ config = DEFAULT_CONFIG,
560
+ logger = console,
561
+ notes = new Map(),
562
+ mediaDownloader = null,
563
+ ) {
564
+ const triggers = (config.triggers ?? []).filter((t) => typeof t === 'string' && t.length > 0);
565
+ const ignoreCase = config.ignoreCase !== false;
566
+ const faces = config.faces !== false;
567
+ const historyAttachments = config.historyAttachments !== false;
568
+
569
+ return async function qqbotGuard(ctx, next) {
570
+ try {
571
+ const msg = ctx?.message;
572
+ if (faces && typeof msg?.content === 'string') {
573
+ const normalized = normalizeFaces(msg.content);
574
+ if (normalized !== msg.content) msg.content = normalized;
575
+ }
576
+ if (historyAttachments && msg?.kind === 'group' && msg?.messageId) {
577
+ if (summarizeAttachments(msg.attachments)) {
578
+ if (notes.size > 200) notes.clear();
579
+ // 附件得在这里就落盘:qqbot 的 attachmentProcessor 排在 mentionGate 之后,
580
+ // 没触发关键词的群消息根本走不到那一步,历史里就只剩文件名、点不开。
581
+ let paths = new Map();
582
+ if (mediaDownloader) {
583
+ try {
584
+ paths = (await mediaDownloader(msg.attachments)) ?? new Map();
585
+ } catch (err) {
586
+ logger.warn?.(`[qqbot-guard] 附件预下载失败(已忽略): ${err?.message ?? err}`);
587
+ }
588
+ }
589
+ notes.set(
590
+ msg.messageId,
591
+ msg.attachments.slice(0, 6).map((a) => ({
592
+ type: a?.type,
593
+ filename: a?.filename,
594
+ voiceText: a?.voiceText,
595
+ localPath: paths.get?.(a?.filename) ?? '',
596
+ })),
597
+ );
598
+ }
599
+ }
600
+ // 只处理群聊;私聊本来就 alwaysAnswer
601
+ if (msg?.kind === 'group') {
602
+ const appId = ctx?.bot?.appId;
603
+ let raw = typeof msg.content === 'string' ? msg.content : '';
604
+ // 真实 @bot:把 `<@appId>` 从正文挪进 mentions。
605
+ // historyBuffer 在门控之前记录 content,标记留在正文里会被拼进群历史。
606
+ if (appId) {
607
+ const selfTag = new RegExp(`<@!?${escapeRegExp(appId)}>\\s*`, 'g');
608
+ if (selfTag.test(raw)) {
609
+ markMention(msg, { is_you: true });
610
+ raw = raw.replace(selfTag, '').trim();
611
+ msg.content = raw;
612
+ logger.debug?.('[qqbot-guard] 已把 <@appId> 移进 mentions');
613
+ }
614
+ }
615
+ if (triggers.length > 0 && raw) {
616
+ const already = Array.isArray(msg.mentions) && msg.mentions.some((m) => m?.is_you === true);
617
+ if (!already) {
618
+ const hay = ignoreCase ? raw.toLowerCase() : raw;
619
+ const hit = triggers.find((t) => hay.includes(ignoreCase ? t.toLowerCase() : t));
620
+ if (hit) {
621
+ markMention(msg, { is_you: true, guarded: true });
622
+ ctx.state = ctx.state ?? {};
623
+ ctx.state.qqbotGuard = { trigger: hit };
624
+ logger.debug?.(`[qqbot-guard] keyword "${hit}" → 伪造 mentions.is_you,放行`);
625
+ }
626
+ }
627
+ }
628
+ }
629
+ } catch (err) {
630
+ // 门禁本身绝不把消息链路带崩
631
+ logger.error?.(`[qqbot-guard] guard 异常(已忽略): ${err?.message ?? err}`);
632
+ }
633
+ await next();
634
+ };
635
+ }
636
+
637
+ const STORE_PATCH_FLAG = Symbol.for('qqbot-guard.historyStore');
638
+
639
+ /**
640
+ * 让群历史带上附件摘要。
641
+ *
642
+ * 做法:包装共享 `HistoryStore.append`,把 guard 在链头算好的附件摘要拼进 `content`
643
+ * 后再落库。只影响历史条目,不动 `ctx.message.content`(当前消息仍由 inbound 用
644
+ * `Image: /path` 正常渲染)。幂等:打过的 store 不会重复包装。
645
+ *
646
+ * @param {object} store history store 单例
647
+ * @param {Map<string, string>} notes messageId → 附件摘要
648
+ * @param {object} logger 日志
649
+ * @returns {boolean} 是否已安装(含“本来就有”)
650
+ */
651
+ /**
652
+ * 包装共享 history store 的 `list()`:读出历史时给带附件的条目补一句摘要。
653
+ *
654
+ * 改 `list` 而不是 `append` 的原因:本地路径要等附件下载完才存在,
655
+ * 而 `downloadMediaAttachments()` 在中间件链之后才跑,落库那一刻根本拿不到路径。
656
+ *
657
+ * @param {object} store getHistoryStore() 返回的单例
658
+ * @param {Map<string, Array>} notes messageId → 附件数组
659
+ * @param {object} [logger]
660
+ * @param {{mediaRoot?: string}} [options] mediaRoot:媒体目录,用来把路径补回去
661
+ */
662
+ export function patchHistoryStore(store, notes, logger = console, options = {}) {
663
+ if (!store || typeof store.list !== 'function') return false;
664
+ if (store[STORE_PATCH_FLAG]) {
665
+ const state = store[STORE_PATCH_FLAG];
666
+ state.notes = notes;
667
+ if (options.mediaRoot) state.mediaRoot = options.mediaRoot;
668
+ return true;
669
+ }
670
+ const baseList = store.list.bind(store);
671
+ const state = { notes, mediaRoot: options.mediaRoot ?? '' };
672
+ const patched = function list(key, limit) {
673
+ const entries = baseList(key, limit) ?? [];
674
+ if (!state.notes || state.notes.size === 0) return entries;
675
+ return entries.map((entry) => {
676
+ try {
677
+ const attachments = state.notes.get(entry?.messageId);
678
+ if (!attachments) return entry;
679
+ const content = typeof entry.content === 'string' ? entry.content : '';
680
+ // 已经带过附件标注的条目不再重复追加
681
+ if (content.includes('[图片「') || content.includes('[附件「')) return entry;
682
+ const suffix = summarizeAttachments(attachments, state.mediaRoot);
683
+ return suffix ? { ...entry, content: `${content}${suffix}` } : entry;
684
+ } catch (err) {
685
+ logger.warn?.(`[qqbot-guard] 历史附件标注失败(已忽略): ${err?.message ?? err}`);
686
+ return entry;
687
+ }
688
+ });
689
+ };
690
+ patched[STORE_PATCH_FLAG] = state;
691
+ store.list = patched;
692
+ store[STORE_PATCH_FLAG] = state;
693
+ return true;
694
+ }
695
+
696
+ /**
697
+ * 造一个“把附件下到本地”的函数,复用 qqbot 自己的下载器(落盘规则、体积上限都一致)。
698
+ *
699
+ * 为什么要预下载:qqbot 的 `attachmentProcessor` 是链上第 13 层,排在 mentionGate(第 5 层)
700
+ * 后面;群里没触发关键词的消息被门控拦下,压根走不到下载那一步,于是历史里只有文件名。
701
+ *
702
+ * @param {string} qqbotDir dsh-qqbot 包目录
703
+ * @param {object} cfg 插件配置
704
+ * @param {object} log logger
705
+ * @returns {((attachments: Array) => Promise<Map<string, string>>) | null}
706
+ */
707
+ function makeMediaDownloader(qqbotDir, cfg, log) {
708
+ if (cfg.historyMedia === false) return null;
709
+ let impl = null;
710
+ return async function downloadAttachments(attachments) {
711
+ if (!Array.isArray(attachments) || attachments.length === 0) return new Map();
712
+ if (!impl) {
713
+ try {
714
+ const mod = await import(
715
+ pathToFileURL(join(qqbotDir, 'dist', 'transport', 'attachment.js')).href
716
+ );
717
+ impl = typeof mod.downloadMediaAttachments === 'function' ? mod.downloadMediaAttachments : null;
718
+ } catch (err) {
719
+ log.warn?.(`[qqbot-guard] 找不到 qqbot 的下载器,历史附件不落盘: ${err?.message ?? err}`);
720
+ impl = null;
721
+ }
722
+ if (!impl) return new Map();
723
+ }
724
+ const media = { enabled: true, maxMB: cfg.historyMediaMaxMB ?? 20 };
725
+ const files = await impl(attachments, media, log);
726
+ return new Map((files ?? []).map((f) => [f.filename, f.localPath]));
727
+ };
728
+ }
729
+
730
+ // ────────────────────────── 补丁安装 ──────────────────────────
731
+
732
+ /**
733
+ * 给 QQBot 类打懒注入补丁。
734
+ * 幂等:重复调用(含 HMR 重载)只装一次。
735
+ * @returns {{ installed: boolean, restored?: () => void, reason?: string }}
736
+ */
737
+ export function installGuard(QQBot, guard, logger = console) {
738
+ if (typeof QQBot?.prototype?.handleInboundMessage !== 'function') {
739
+ return { installed: false, reason: 'QQBot.prototype.handleInboundMessage 不存在,版本可能已变' };
740
+ }
741
+ const current = QQBot.prototype.handleInboundMessage;
742
+ if (current?.[PATCH_FLAG]) {
743
+ current.guard = guard; // 热重载时换用新的 guard 实例
744
+ return { installed: true, reason: 'already-patched' };
745
+ }
746
+
747
+ const original = current;
748
+ const patched = function handleInboundMessage(raw, ...rest) {
749
+ try {
750
+ if (!this.__qqbotGuardInstalled) {
751
+ if (Array.isArray(this.middlewares)) {
752
+ this.middlewares.unshift(patched.guard);
753
+ this.__qqbotGuardInstalled = true;
754
+ logger.debug?.('[qqbot-guard] 已挂载到中间件链头部');
755
+ }
756
+ }
757
+ } catch (err) {
758
+ logger.error?.(`[qqbot-guard] 挂载失败: ${err?.message ?? err}`);
759
+ }
760
+ return original.call(this, raw, ...rest);
761
+ };
762
+ patched[PATCH_FLAG] = true;
763
+ patched.guard = guard;
764
+ patched.original = original;
765
+
766
+ QQBot.prototype.handleInboundMessage = patched;
767
+ return {
768
+ installed: true,
769
+ restored: () => {
770
+ if (QQBot.prototype.handleInboundMessage === patched) {
771
+ QQBot.prototype.handleInboundMessage = original;
772
+ }
773
+ },
774
+ };
775
+ }
776
+
777
+ // ────────────────────────── cordis 插件入口 ──────────────────────────
778
+
779
+ export async function apply(ctx, config) {
780
+ const log = pickLogger(ctx);
781
+ const cfg = mergeConfig(config, log);
782
+
783
+ if (cfg.enabled === false) {
784
+ log.info('[qqbot-guard] enabled=false,跳过(qqbot 行为保持原样)');
785
+ return;
786
+ }
787
+
788
+ const qqbot = findQqbotPackage(cfg, log);
789
+ if (!qqbot) return;
790
+
791
+ let sdkInfo;
792
+ try {
793
+ sdkInfo = resolveSdk(qqbot.dir);
794
+ } catch (err) {
795
+ log.error(`[qqbot-guard] 解析 ${SDK_PKG} 失败: ${err?.message ?? err}`);
796
+ return;
797
+ }
798
+
799
+ const sdkVersion = parseVersion(sdkInfo.version);
800
+ const supported =
801
+ sdkVersion && cmp(sdkVersion, SDK_MIN) >= 0 && cmp(sdkVersion, SDK_MAX_EXCLUSIVE) < 0;
802
+ if (!supported) {
803
+ const msg = `[qqbot-guard] ${SDK_PKG} 版本 ${sdkInfo.version ?? '未知'} 不在白名单 ${SDK_MIN.join('.')}~<${SDK_MAX_EXCLUSIVE.join('.')}`;
804
+ if (cfg.strictVersion) {
805
+ log.error(`${msg};strictVersion=true,放弃安装`);
806
+ return;
807
+ }
808
+ log.warn(`${msg};仍尝试安装(内部只用公开字段 middlewares / handleInboundMessage,多半可用)`);
809
+ }
810
+
811
+ let sdk;
812
+ try {
813
+ sdk = await import(pathToFileURL(sdkInfo.entry).href);
814
+ } catch (err) {
815
+ // 退回按包名解析,尽量拿同一个实例(可能因 realpath 不同而失效)
816
+ try {
817
+ sdk = await import(SDK_PKG);
818
+ log.warn(`[qqbot-guard] 按路径导入失败,已回退到裸包名导入:${err?.message ?? err}`);
819
+ } catch (err2) {
820
+ log.error(`[qqbot-guard] 导入 ${SDK_PKG} 失败: ${err2?.message ?? err2}`);
821
+ return;
822
+ }
823
+ }
824
+
825
+ const notes = new Map();
826
+ const mediaDownloader = makeMediaDownloader(qqbot.dir, cfg, log);
827
+ const guard = createGuard(cfg, log, notes, mediaDownloader);
828
+ const result = installGuard(sdk.QQBot, guard, log);
829
+ if (!result.installed) {
830
+ log.error(`[qqbot-guard] 补丁未安装:${result.reason}`);
831
+ return;
832
+ }
833
+
834
+ let historyPatched = false;
835
+ let mediaRoot = '';
836
+ if (cfg.historyAttachments !== false) {
837
+ try {
838
+ const storeMod = await import(
839
+ pathToFileURL(join(qqbot.dir, 'dist', 'features', 'history-store.js')).href
840
+ );
841
+ // 附件落盘的目录:上游从 media-cleaner 导出 MEDIA_ROOT(~/.dsh-qqbot/media),
842
+ // 拿不到就按同一个默认值兜底(路径标注是锦上添花,失败不影响摘要)。
843
+ try {
844
+ const mediaMod = await import(
845
+ pathToFileURL(join(qqbot.dir, 'dist', 'media', 'media-cleaner.js')).href
846
+ );
847
+ mediaRoot = typeof mediaMod.MEDIA_ROOT === 'string' ? mediaMod.MEDIA_ROOT : '';
848
+ } catch {
849
+ mediaRoot = join(homedir(), '.dsh-qqbot', 'media');
850
+ }
851
+ historyPatched = patchHistoryStore(storeMod.getHistoryStore?.(), notes, log, { mediaRoot });
852
+ if (!historyPatched) log.warn('[qqbot-guard] 没找到 history store,历史附件摘要未启用');
853
+ } catch (err) {
854
+ log.warn(`[qqbot-guard] 历史附件摘要未启用: ${err?.message ?? err}`);
855
+ }
856
+ }
857
+
858
+ if (cfg.verbose) {
859
+ const cfgFile = resolveConfigFile(cfg);
860
+ log.info(
861
+ `[qqbot-guard] 就绪:qqbot=${qqbot.pkg?.version ?? '?'} sdk=${sdkInfo.version ?? '?'} ` +
862
+ `triggers=${JSON.stringify(cfg.triggers)} 配置源=${existsSync(cfgFile ?? '') ? cfgFile : 'cordis/默认'} ` +
863
+ `表情=${cfg.faces !== false ? '开' : '关'} 历史附件=${historyPatched ? '开' : '关'} ` +
864
+ `注入方式=lazy-unshift + 伪造 mentions(与加载顺序无关)`,
865
+ );
866
+ }
867
+
868
+ if (result.restored && typeof ctx?.on === 'function') {
869
+ ctx.on('dispose', () => {
870
+ result.restored();
871
+ log.info('[qqbot-guard] 已卸载补丁');
872
+ });
873
+ }
874
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "dsh-qqbot-guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "qqbot 增强层:群聊关键词放行 + 历史上下文 + 进程内补丁(不改 qqbot 文件)",
6
+ "license": "MIT",
7
+ "main": "./index.js",
8
+ "exports": {
9
+ ".": "./index.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "cordis.patch.yml",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "selfcheck": "node selfcheck.mjs",
18
+ "test": "node --test *.test.mjs"
19
+ },
20
+ "keywords": [
21
+ "dsh",
22
+ "dsh-plugin",
23
+ "deepseek-harness",
24
+ "cordis",
25
+ "plugin",
26
+ "qqbot",
27
+ "qq",
28
+ "tencent-connect"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/anwesc/dsh-qqbot-guard.git"
33
+ },
34
+ "homepage": "https://github.com/anwesc/dsh-qqbot-guard#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/anwesc/dsh-qqbot-guard/issues"
37
+ },
38
+ "dsh": {
39
+ "manifestVersion": 1,
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ }
43
+ },
44
+ "peerDependencies": {
45
+ "@tencent-connect/dsh-qqbot": ">=0.5.3"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@tencent-connect/dsh-qqbot": {
49
+ "optional": true
50
+ }
51
+ }
52
+ }