qq-bot-roost 0.9.2 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
11
  <img src="https://img.shields.io/badge/tests-700%2B%20passed-brightgreen" alt="测试 700+ 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-0.9.2-blueviolet" alt="版本 0.9.2">
13
+ <img src="https://img.shields.io/badge/version-0.9.5-blueviolet" alt="版本 0.9.5">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
  <a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
16
16
 
@@ -474,7 +474,7 @@ openid 完全不同。共享 `roles.json` 等于所有权限记录全部对不
474
474
  ## 开发
475
475
 
476
476
  ```bash
477
- npm test # 450 项(selftest 431 + qrlogin 8 + deps 11)
477
+ npm test # 609 项(selftest 590 + qrlogin 8 + deps 11)
478
478
  npm run test:e2e # 端到端 168 项(起 mock 服务打全部接口,跑在临时目录)
479
479
  npm run test:all # 全量约 618 项:自检 + 扫码 + 依赖 + e2e(真机驱动要真机,没配就跳过)
480
480
  npm run check:secrets # 扫硬编码凭据(CI 也跑这个,提交前建议过一遍)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.9.2",
3
+ "version": "0.9.5",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/src/api.js CHANGED
@@ -9,6 +9,20 @@ const log = createLogger('api');
9
9
  /** 富媒体类型,对应官方 file_type */
10
10
  export const FILE_TYPE = { IMAGE: 1, VIDEO: 2, VOICE: 3, FILE: 4 };
11
11
 
12
+ /**
13
+ * 富媒体软限制(字节)。官方口径:图片 20MB / 视频 30MB / 语音 20MB / 文件 200MB;
14
+ * 超软限会被服务端拒(普通 base64 上传接口直接 413)。
15
+ *
16
+ * `message.js` 的 `replyMedia` 用它做「图片超 20MB 自动降级为文件」;
17
+ * `uploadLocalFile` 拿它当最后一道兜底(防直接调它传超大文件)。
18
+ */
19
+ export const MEDIA_SOFT_LIMIT = {
20
+ [FILE_TYPE.IMAGE]: 20 * 1024 * 1024,
21
+ [FILE_TYPE.VIDEO]: 30 * 1024 * 1024,
22
+ [FILE_TYPE.VOICE]: 20 * 1024 * 1024,
23
+ [FILE_TYPE.FILE]: 200 * 1024 * 1024,
24
+ };
25
+
12
26
  /**
13
27
  * 被动回复序号的**最后一道**兜底上限。
14
28
  *
@@ -262,11 +276,28 @@ export class QQApi {
262
276
  let cacheKey = null;
263
277
  try {
264
278
  const st = await fs.stat(filePath);
279
+ /**
280
+ * 软限制前置校验:普通 base64 上传接口对富媒体有大小上限,
281
+ * 超了服务端直接 413。在这里先挡一道,报一个能看懂的错误,
282
+ * 别让调用方拿到「HTTP 413 ... <html>」这种垃圾信息。
283
+ */
284
+ const limit = MEDIA_SOFT_LIMIT[fileType];
285
+ if (limit && st.size > limit) {
286
+ const mb = (st.size / 1024 / 1024).toFixed(1);
287
+ const cap = Math.round(limit / 1024 / 1024);
288
+ throw new Error(`${filePath} 有 ${mb}MB,超过 ${cap}MB 上限,发不出去(先压缩或换张小图)`);
289
+ }
265
290
  cacheKey = `${scene}:${enc(target)}:${fileType}:file:${filePath}:${st.size}:${Math.floor(st.mtimeMs)}`;
266
291
  const hit = this.mediaCache.get(cacheKey);
267
292
  if (hit && Date.now() - hit.at < MEDIA_TTL) return hit.value;
268
293
  if (hit) this.mediaCache.delete(cacheKey);
269
- } catch {
294
+ } catch (e) {
295
+ /**
296
+ * stat 失败或超限都落到这里。stat 失败(文件不存在 / 没权限)时
297
+ * 不能当缓存命中,继续往下走让 `readFile` 抛真正的错误;
298
+ * 超限是**明确拒绝**,必须直接把错误往上抛,不能悄悄读文件再传一遍。
299
+ */
300
+ if (/上限/.test(e?.message ?? '')) throw e;
270
301
  /* stat 失败就按无缓存走,readFile 会抛真正的错误 */
271
302
  }
272
303
 
package/src/gitee.js CHANGED
@@ -57,6 +57,44 @@ export function giteeRawUrl(repo, ref, filePath) {
57
57
  return `https://gitee.com/${repo}/raw/${encodeURIComponent(ref)}/${p}`;
58
58
  }
59
59
 
60
+ /** blob 地址:`https://gitee.com/api/v5/repos/{repo}/git/blobs/{sha}` */
61
+ export function giteeBlobUrl(repo, sha) {
62
+ return `${API}/repos/${repo}/git/blobs/${encodeURIComponent(sha)}`;
63
+ }
64
+
65
+ /**
66
+ * 用 git blob 接口取单个文件内容,绕开被内容审查挡住的 raw CDN。
67
+ *
68
+ * 背景:Gitee 的 raw 端点会对个别文件返回 451(内容审查),而**同一个
69
+ * blob 走 git 对象接口是 200**——raw 走的是被审查的 CDN,blob 是 git 对象
70
+ * 仓库,不经过那层审查(实测 douluo 的 content-pack.sql 等 4 个文件)。
71
+ *
72
+ * 前置条件:调用方必须拿到这个 blob 的 `sha`。`fetchGiteeTree` 列清单时
73
+ * trees API 每个条目都带 `sha`,正好够用。
74
+ *
75
+ * @param {string} repo `owner/name`
76
+ * @param {string} sha blob 的 40 位十六进制 sha(来自 trees 清单)
77
+ * @param {string} [token] Gitee 私人令牌(选填,加在 API 调用上)
78
+ * @param {number} [timeoutMs]
79
+ * @returns {Promise<Buffer>} 文件原始字节
80
+ */
81
+ async function fetchBlob(repo, sha, token = '', timeoutMs = 120000) {
82
+ const q = new URLSearchParams();
83
+ if (token) q.set('access_token', token);
84
+ const url = `${giteeBlobUrl(repo, sha)}?${q}`;
85
+ const res = await fetch(url, {
86
+ headers: { 'User-Agent': UA, accept: 'application/json' },
87
+ signal: AbortSignal.timeout(Math.min(timeoutMs, 30000)),
88
+ });
89
+ if (!res.ok) throw new Error(`blob HTTP ${res.status}`);
90
+ const data = await res.json().catch(() => null);
91
+ const content = data?.content;
92
+ if (typeof content !== 'string' || !content) throw new Error('blob 接口没返回内容');
93
+ const buf = Buffer.from(content, data?.encoding === 'base64' ? 'base64' : 'utf8');
94
+ if (!buf.length) throw new Error('blob 内容是空的');
95
+ return buf;
96
+ }
97
+
60
98
  /**
61
99
  * 把仓库某个 ref 的内容拉到本地目录。
62
100
  *
@@ -177,7 +215,26 @@ export async function fetchGiteeTree({
177
215
  await sleep(attempt * 1000); // 1s、2s 退避
178
216
  }
179
217
  }
180
- if (!res) throw lastErr ?? new Error('未知错误');
218
+ if (!res) {
219
+ // raw 全部失败(常见是 451 内容审查)。tree 条目里带了 sha,
220
+ // 改走 git blob 接口兜底——blob 走的是 git 对象仓库,不经过被
221
+ // 审查的 raw CDN,同一份内容在 GitHub 上能下、在 blob 上也能下。
222
+ // 只有 451 才兜底:404 是文件真没有,blob 也拿不到,直接报错。
223
+ if (lastErr?.message?.startsWith('HTTP 451') && f.sha) {
224
+ try {
225
+ const buf = await fetchBlob(repo, f.sha, token, timeoutMs);
226
+ await fs.mkdir(path.dirname(dest), { recursive: true });
227
+ await fs.writeFile(dest, buf);
228
+ done += 1;
229
+ onProgress?.(`${done}/${files.length} ${f.path}`);
230
+ log.info(`451 文件已走 blob 接口补回 ${f.path}`);
231
+ continue;
232
+ } catch (e) {
233
+ throw new Error(`blob 兜底也失败:${e.message}`);
234
+ }
235
+ }
236
+ throw lastErr ?? new Error('未知错误');
237
+ }
181
238
  const buf = Buffer.from(await res.arrayBuffer());
182
239
  if (!buf.length) throw new Error('内容是空的');
183
240
  await fs.mkdir(path.dirname(dest), { recursive: true });
package/src/instance.js CHANGED
@@ -59,7 +59,16 @@ function pidLooksLikeNode(pid) {
59
59
  */
60
60
  if (process.platform === 'linux') {
61
61
  const comm = readFileSync(`/proc/${pid}/comm`, 'utf8').trim();
62
- return comm ? /node/i.test(comm) : false;
62
+ /**
63
+ * 读到空字符串时**按活着算**,不是按死了算。
64
+ *
65
+ * 判断不出来的方向要选保守的那个:当成死 → 删记录 → 放行第二次认领
66
+ * → 同一个 AppID 双开 → 4902 互踢,正是这套机制存在的理由。
67
+ * 当成活 → 顶多让用户手动清一条残留记录(有 `--force`)。
68
+ *
69
+ * 真实的误判代价不对称,所以拿不准就往"活着"倒。
70
+ */
71
+ return comm ? /node/i.test(comm) : true;
63
72
  }
64
73
  const comm = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
65
74
  encoding: 'utf8',
package/src/message.js CHANGED
@@ -1,5 +1,6 @@
1
+ import fs from 'node:fs/promises';
1
2
  import { createLogger } from './logger.js';
2
- import { FILE_TYPE } from './api.js';
3
+ import { FILE_TYPE, MEDIA_SOFT_LIMIT } from './api.js';
3
4
  import { messageKindOf, EVENTS } from './events.js';
4
5
  import { renderMenuMd, mdToPlain } from './markdown.js';
5
6
 
@@ -670,9 +671,30 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
670
671
  const target = kind === 'group' ? groupId : userId;
671
672
  if (scene !== 'group' && scene !== 'c2c') return null;
672
673
 
674
+ /**
675
+ * 图片超软限(20MB)自动降级为文件。
676
+ *
677
+ * 普通 base64 上传接口对图片有 20MB 上限,超了服务端直接 413。
678
+ * 但文件(file_type=4)上限是 200MB,所以本地大图不该直接报错——
679
+ * 改成按「文件」发出去,用户收到的是文件卡片(可下载查看)。
680
+ * 官方「超软限降级为文件」的语义,落到这里实现。
681
+ */
682
+ let effectiveType = fileType;
683
+ if (local && fileType === FILE_TYPE.IMAGE) {
684
+ try {
685
+ const st = await fs.stat(source);
686
+ if (st.size > MEDIA_SOFT_LIMIT[FILE_TYPE.IMAGE]) {
687
+ effectiveType = FILE_TYPE.FILE;
688
+ log.info(`图片 ${(st.size / 1024 / 1024).toFixed(1)}MB 超过 20MB 软限制,降级按文件发送`);
689
+ }
690
+ } catch {
691
+ /* stat 失败按原类型走,让 uploadLocalFile 抛真正的错误 */
692
+ }
693
+ }
694
+
673
695
  const up = local
674
- ? await api.uploadLocalFile(scene, target, source, fileType)
675
- : await api.uploadMedia(scene, target, { fileType, url: source });
696
+ ? await api.uploadLocalFile(scene, target, source, effectiveType)
697
+ : await api.uploadMedia(scene, target, { fileType: effectiveType, url: source });
676
698
  if (!up?.file_info) throw new Error('未返回 file_info');
677
699
 
678
700
  const content = text ? String(text) : ' ';
@@ -505,9 +505,14 @@ export function detectUpdates(indexPlugins, localVersions = {}) {
505
505
  * 每次几十秒。参考 BetterGI 的 ScriptRepoUpdater——先拉中央仓库,
506
506
  * 再从里面逐个检出。调用方用完必须调 `cleanupRepo()`。
507
507
  *
508
+ * 只更新单个插件时传 `only`(如 `['plugins/aichat/']`),就只拉那一个
509
+ * 插件目录。两个好处:①下载量少一半;②**不会被别的插件的坏文件连坐**——
510
+ * 否则某个插件在下载源上有个文件拉不动,整仓失败,更新其它插件也一起黄。
511
+ *
512
+ * @param {string[]} [only] 只拉这些路径前缀(目录以 `/` 结尾),透传给下载层
508
513
  * @returns {Promise<{tmp:string, index:object}>}
509
514
  */
510
- export async function syncRepo({ repo, branch = 'main', indexUrl } = {}) {
515
+ export async function syncRepo({ repo, branch = 'main', indexUrl, only = null } = {}) {
511
516
  const index = await fetchIndex(indexUrl);
512
517
  if (!repo) repo = index.repo;
513
518
  if (!repo) throw new Error('索引里缺少 repo 字段,无法同步');
@@ -519,6 +524,7 @@ export async function syncRepo({ repo, branch = 'main', indexUrl } = {}) {
519
524
  repo,
520
525
  branch,
521
526
  destDir: tmp,
527
+ only,
522
528
  // 索引里带了校验值就验;没带会在 tarball 那边 warn 一句
523
529
  expectSha256: index?.archive?.sha256 ?? index?.sha256 ?? null,
524
530
  timeoutMs: 180000,
package/src/web/server.js CHANGED
@@ -2625,8 +2625,14 @@ export async function startWebServer({
2625
2625
  return json(res, 400, { error: `${id} 已经是 ${cur},没有新版` });
2626
2626
  }
2627
2627
 
2628
- // 拉一次仓库,只更新指定的这一个
2629
- repo = await syncRepo({ repo: idx.repo, branch: idx.branch || 'main', indexUrl: src });
2628
+ // 拉一次仓库,只更新指定的这一个;only 让它只下这个插件的目录,
2629
+ // 既省流量,又不会被其它插件的坏文件连坐(见 syncRepo 注释)
2630
+ repo = await syncRepo({
2631
+ repo: idx.repo,
2632
+ branch: idx.branch || 'main',
2633
+ indexUrl: src,
2634
+ only: [`plugins/${id}/`],
2635
+ });
2630
2636
  const r = await updatePlugins({
2631
2637
  tmp: repo.tmp,
2632
2638
  index: repo.index,