qq-bot-roost 0.9.6 → 0.9.8

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
@@ -8,9 +8,9 @@
8
8
 
9
9
  <img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
- <img src="https://img.shields.io/badge/tests-700%2B%20passed-brightgreen" alt="测试 700+ 项">
11
+ <img src="https://img.shields.io/badge/tests-679%20passed-brightgreen" alt="测试 679 项">
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.6-blueviolet" alt="版本 0.9.6">
13
+ <img src="https://img.shields.io/badge/version-0.9.8-blueviolet" alt="版本 0.9.8">
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
 
@@ -23,8 +23,8 @@
23
23
  用 Node.js 从零实现 QQ 开放平台 v2 协议,**不依赖任何第三方包**——装个 Node 22 就能跑,
24
24
  不需要 `npm install`,**装插件和更新框架也不需要 git**(下载 GitHub 的 tar 包自己解,不调外部命令)。
25
25
 
26
- 官方接口比第三方协议严格得多:被动回复只有 5 分钟且同一条最多回 5 次、同一会话每秒最多 5 条、
27
- 主动消息单会话 20 条/分钟且每群每天 1000 条。
26
+ 官方接口比第三方协议严格得多:群聊被动回复 5 分钟、同一条最多回 5 次(单聊 60 分钟 / 4 次)、
27
+ 发送接口 100 QPS、主动消息单会话 20 条/分钟且每群每天 1000 条。
28
28
  这些限制框架已经统一兜住,写插件时不用算时间、不用管额度,调 `msg.reply()` 就行。
29
29
 
30
30
  插件有两种形态,互不影响:
@@ -62,7 +62,7 @@
62
62
 
63
63
  **官方接口这一层**
64
64
 
65
- - **限制全兜底**:出站调度器统一处理 5 分钟窗口、每秒 5 条限速、主动消息每日额度、断线拒发、审核中识别——插件只管调 `msg.reply()`
65
+ - **限制全兜底**:出站调度器统一处理被动窗口(群 5 分钟 / 单聊 60 分钟)、会话内限速、主动消息每日额度、断线拒发、审核中识别——插件只管调 `msg.reply()`
66
66
  - **`msg_seq` 自动维护**:同一 `msg_id` 被动回复必须递增,这是官方硬要求,漏一次就发不出去
67
67
  - **免@粘性**:@ 过一次后 N 分钟内该会话免@ 也算在跟机器人说话
68
68
  - **多轮对话**:`ctx.prompt()` 问一句等回答,文字游戏的地基
@@ -124,6 +124,7 @@
124
124
  - 5 种作用域 × 数字等级(0 主人 / 1 管理员 / 2 群管 / 3 助手 / 10+ 插件区)
125
125
  - 一个人在不同群、不同插件里可以有完全不同的权限,互不干扰
126
126
  - 只能授予数字更大的等级,不能给自己或同级提权
127
+ - **权限管的是「谁能让机器人做什么」,不是「插件代码能做什么」。** 内建插件跑在主进程、外部插件跑在子进程,两者都拥有和你相同的文件与网络权限。装来路不明的插件,等于让它的作者以你的身份运行代码。
127
128
 
128
129
  **群管能力**
129
130
 
@@ -474,7 +475,7 @@ openid 完全不同。共享 `roles.json` 等于所有权限记录全部对不
474
475
  ## 开发
475
476
 
476
477
  ```bash
477
- npm test # 609 项(selftest 590 + qrlogin 8 + deps 11)
478
+ npm test # 636 项(selftest 617 + qrlogin 8 + deps 11)
478
479
  npm run test:e2e # 端到端 168 项(起 mock 服务打全部接口,跑在临时目录)
479
480
  npm run test:all # 全量约 618 项:自检 + 扫码 + 依赖 + e2e(真机驱动要真机,没配就跳过)
480
481
  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.6",
3
+ "version": "0.9.8",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,9 +29,9 @@
29
29
  "start": "node src/main.js",
30
30
  "mock": "node src/main.js --mock",
31
31
  "check": "node src/main.js --check",
32
- "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs",
32
+ "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs",
33
33
  "test:e2e": "node test/e2e.mjs",
34
- "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node test/e2e.mjs && node test/live-driver.mjs",
34
+ "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node test/e2e.mjs && node test/live-driver.mjs",
35
35
  "check:secrets": "node scripts/check-secrets.mjs",
36
36
  "create": "node scripts/create-plugin.mjs",
37
37
  "test:plugin": "node scripts/test-plugin.mjs",
@@ -39,7 +39,8 @@
39
39
  "market:index": "node scripts/build-market-index.mjs",
40
40
  "sums": "node scripts/build-sums.mjs",
41
41
  "sums:check": "node scripts/build-sums.mjs --check",
42
- "sums:verify": "node scripts/build-sums.mjs --verify"
42
+ "sums:verify": "node scripts/build-sums.mjs --verify",
43
+ "test:coverage": "node --experimental-test-coverage --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-include=src/trust.js --test-coverage-include=src/plugin/manifest.js --test-coverage-include=src/trace.js --test test/trust.mjs test/manifest.mjs test/trace.mjs"
43
44
  },
44
45
  "author": {
45
46
  "name": "倦飞之鸟"
package/src/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs/promises';
2
3
  import { createLogger } from './logger.js';
3
4
  /** 路径里的 id 一律编码:openid / 群号是外部数据,含 / 会拼出别的路径 */
@@ -11,10 +12,10 @@ export const FILE_TYPE = { IMAGE: 1, VIDEO: 2, VOICE: 3, FILE: 4 };
11
12
 
12
13
  /**
13
14
  * 富媒体软限制(字节)。官方口径:图片 20MB / 视频 30MB / 语音 20MB / 文件 200MB;
14
- * 超软限会被服务端拒(普通 base64 上传接口直接 413)。
15
+ * 超软限**降级为文件**(file_type=4),超硬限制(200MB)直接报错。
15
16
  *
16
- * `message.js` 的 `replyMedia` 用它做「图片超 20MB 自动降级为文件」;
17
- * `uploadLocalFile` 拿它当最后一道兜底(防直接调它传超大文件)。
17
+ * `message.js` 的 `replyMedia` 用它算降级后的 file_type;
18
+ * `uploadLocalFile` 拿它当最后一道兜底。
18
19
  */
19
20
  export const MEDIA_SOFT_LIMIT = {
20
21
  [FILE_TYPE.IMAGE]: 20 * 1024 * 1024,
@@ -23,6 +24,27 @@ export const MEDIA_SOFT_LIMIT = {
23
24
  [FILE_TYPE.FILE]: 200 * 1024 * 1024,
24
25
  };
25
26
 
27
+ /** 所有富媒体类型的硬上限。超过它官方直接报 850031,连分片都没用 */
28
+ export const MEDIA_HARD_LIMIT = 200 * 1024 * 1024;
29
+
30
+ /**
31
+ * 二进制直传(`file_data`)的上限:**5MB**。
32
+ *
33
+ * 官方原文:「直接上传二进制数据……数据大小不能超过 5M,超过建议使用分片上传」。
34
+ * 实测网关对请求体的容忍比 5MB 大一些(约 10MB),但那是运气不是承诺——
35
+ * 一张 24.4MB 的图 base64 后 32.5MB,网关直接回 `413 Request Entity Too Large`
36
+ * (stgw 的 HTML 报错,连 QQ 的业务错误码都没有)。
37
+ *
38
+ * 所以超过这个值一律走分片上传,别再赌网关的脾气。
39
+ */
40
+ export const DIRECT_UPLOAD_LIMIT = 5 * 1024 * 1024;
41
+
42
+ /**
43
+ * `md5_10m` 取的字节数:官方写死 10002432(约 9.54MB),不是整数 MB。
44
+ * 这个值用于「秒传判断」,传错了服务端不会报错、只是秒传不生效。
45
+ */
46
+ export const MD5_10M_SIZE = 10_002_432;
47
+
26
48
  /**
27
49
  * 被动回复序号的**最后一道**兜底上限。
28
50
  *
@@ -230,12 +252,22 @@ export class QQApi {
230
252
 
231
253
  /**
232
254
  * 上传富媒体。
255
+ *
256
+ * 三种路径,对应官方 `/files` 的三个入参:
257
+ * · `url` —— URL 上传(平台自己去下载转存)
258
+ * · `fileData` —— 二进制直传(base64,**5MB 以内**,见 `DIRECT_UPLOAD_LIMIT`)
259
+ * · `uploadId` —— 分片上传的合并(分片已由 `uploadLocalFileSharded` 传完)
260
+ *
233
261
  * @param {'group'|'c2c'} scene
234
262
  * @param {string} target group_openid 或 user openid
235
- * @param {object} opts { fileType, url?, fileData?(base64) }
263
+ * @param {object} opts { fileType, url?, fileData?(base64), uploadId?, fileName? }
236
264
  * @returns {Promise<{file_uuid:string, file_info:string, ttl:number}>}
237
265
  */
238
- async uploadMedia(scene, target, { fileType = FILE_TYPE.IMAGE, url, fileData, reuse = true } = {}) {
266
+ async uploadMedia(
267
+ scene,
268
+ target,
269
+ { fileType = FILE_TYPE.IMAGE, url, fileData, uploadId, fileName, reuse = true } = {},
270
+ ) {
239
271
  // 只有按 url 传的才缓存:base64 数据做键得先算哈希,不值当
240
272
  const cacheKey = reuse && url ? `${scene}:${enc(target)}:${fileType}:${url}` : null;
241
273
  if (cacheKey) {
@@ -252,6 +284,14 @@ export class QQApi {
252
284
  const body = { file_type: fileType, srv_send_msg: false };
253
285
  if (url) body.url = url;
254
286
  if (fileData) body.file_data = fileData;
287
+ /**
288
+ * 分片合并:`upload_id` 一进,服务端就走「合并已上传分片」那条路,
289
+ * `url` 可以为空。但 `file_name` 这条路径**必填**(官方字段说明)。
290
+ */
291
+ if (uploadId) {
292
+ body.upload_id = uploadId;
293
+ if (fileName) body.file_name = fileName;
294
+ }
255
295
  const r = await this.#request('POST', path, body);
256
296
 
257
297
  if (cacheKey && r?.file_info) {
@@ -266,26 +306,29 @@ export class QQApi {
266
306
  }
267
307
 
268
308
  /**
269
- * 从本地文件读取并上传(自动转 base64)。
309
+ * 从本地文件读取并上传(**直传**,base64 整包)。
310
+ *
311
+ * 只适合小文件:`file_data` 官方限 5MB(`DIRECT_UPLOAD_LIMIT`),
312
+ * 超过它请用 `uploadLocalFileSharded`——这里不偷偷分片,
313
+ * 因为「直传」这个方法的语义就是一次请求,隐式换成十几次请求会让调用方
314
+ * 对耗时和失败几率的预期全错。
270
315
  *
271
316
  * 带缓存:同一个文件(路径 + 大小 + 修改时间都一样)在 TTL 内不重复上传。
272
- * 图片缓存类插件(本地存几百张图,用户点一次发一张)没有这层的话,
273
- * 每发一次都要把整张图 base64 传一遍,慢且费额度。
274
317
  */
275
318
  async uploadLocalFile(scene, target, filePath, fileType = FILE_TYPE.IMAGE) {
276
319
  let cacheKey = null;
277
320
  try {
278
321
  const st = await fs.stat(filePath);
279
322
  /**
280
- * 软限制前置校验:普通 base64 上传接口对富媒体有大小上限,
281
- * 超了服务端直接 413。在这里先挡一道,报一个能看懂的错误,
282
- * 别让调用方拿到「HTTP 413 ... <html>」这种垃圾信息。
323
+ * 直传上限前置校验:超过 5MB 的 base64 请求体,网关会直接回
324
+ * `413 Request Entity Too Large`(HTML,连业务错误码都没有)。
325
+ * 在这里挡一道,报一个能看懂的错误。
283
326
  */
284
- const limit = MEDIA_SOFT_LIMIT[fileType];
285
- if (limit && st.size > limit) {
327
+ if (st.size > DIRECT_UPLOAD_LIMIT) {
286
328
  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 上限,发不出去(先压缩或换张小图)`);
329
+ throw new Error(
330
+ `${filePath} 有 ${mb}MB,超过直传上限 ${DIRECT_UPLOAD_LIMIT / 1024 / 1024}MB,请改用 uploadLocalFileSharded`,
331
+ );
289
332
  }
290
333
  cacheKey = `${scene}:${enc(target)}:${fileType}:file:${filePath}:${st.size}:${Math.floor(st.mtimeMs)}`;
291
334
  const hit = this.mediaCache.get(cacheKey);
@@ -297,7 +340,7 @@ export class QQApi {
297
340
  * 不能当缓存命中,继续往下走让 `readFile` 抛真正的错误;
298
341
  * 超限是**明确拒绝**,必须直接把错误往上抛,不能悄悄读文件再传一遍。
299
342
  */
300
- if (/上限/.test(e?.message ?? '')) throw e;
343
+ if (/直传上限/.test(e?.message ?? '')) throw e;
301
344
  /* stat 失败就按无缓存走,readFile 会抛真正的错误 */
302
345
  }
303
346
 
@@ -317,6 +360,176 @@ export class QQApi {
317
360
  return up;
318
361
  }
319
362
 
363
+ /**
364
+ * PUT 一个分片到预签名 URL。**裸 fetch,不带 QQ 的任何头。**
365
+ *
366
+ * 为什么不能复用 `#request`:那个方法会拼 `baseUrl + path`(预签名是绝对
367
+ * URL)、强塞 `Content-Type: application/json`(分片是二进制)、强带
368
+ * `Authorization: QQBot …`(预签名 URL 自带签名,多带鉴权头反而会被
369
+ * COS 拒)、超时写死 15 秒(5MB 分片在慢网下要更久)。
370
+ *
371
+ * @param {string} presignedUrl 预签名上传地址
372
+ * @param {Buffer} buf 分片二进制
373
+ * @param {number} [timeoutMs]
374
+ */
375
+ async #putPart(presignedUrl, buf, timeoutMs = 60_000) {
376
+ let res;
377
+ try {
378
+ res = await fetch(presignedUrl, {
379
+ method: 'PUT',
380
+ signal: AbortSignal.timeout(timeoutMs),
381
+ headers: { 'Content-Length': String(buf.length) },
382
+ body: buf,
383
+ });
384
+ } catch (e) {
385
+ const reason = e?.name === 'TimeoutError' ? `超时(${Math.round(timeoutMs / 1000)}s)` : e.message;
386
+ throw new Error(`分片 PUT 网络错误: ${reason}`);
387
+ }
388
+ if (!res.ok) {
389
+ const detail = await res.text().catch(() => '');
390
+ throw new Error(`分片 PUT 失败 HTTP ${res.status}${detail ? `:${detail.slice(0, 200)}` : ''}`);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * 大文件分片上传(官方推荐路径)。
396
+ *
397
+ * 四步(官方文档《富媒体消息概述》):
398
+ * ① `upload_prepare` → upload_id / block_size / parts[] / upload_config
399
+ * ② 逐片 `PUT` 到 `parts[i].presigned_url`
400
+ * ③ 每片成功后 `upload_part_finish` 确认
401
+ * ④ 带 `upload_id` 调 `/files` 合并 → `file_info`
402
+ *
403
+ * 返回值与 `uploadLocalFile` 同形状(`{file_uuid, file_info, ttl}`),
404
+ * 调用方不必分叉。
405
+ *
406
+ * **分片顺序不能乱**:`parts` 按 `index` 排序后逐片上传;并发数听
407
+ * `upload_config.concurrency`(官方默认 1),我们上限 4——再高对
408
+ * 5MB 一片的 COS PUT 没什么收益,反而容易触发服务端侧限流。
409
+ *
410
+ * @param {'group'|'c2c'} scene
411
+ * @param {string} target
412
+ * @param {string} filePath 本地文件
413
+ * @param {number} fileType
414
+ * @param {object} [opts]
415
+ * @param {(m:string)=>void} [opts.log]
416
+ * @returns {Promise<{file_uuid:string, file_info:string, ttl:number}>}
417
+ */
418
+ async uploadLocalFileSharded(scene, target, filePath, fileType = FILE_TYPE.FILE, opts = {}) {
419
+ /**
420
+ * `opts.log` 可能是**函数**,也可能是 `createLogger()` 那种**对象**(有 .info)。
421
+ * 统一成函数再往下用——本机就因为这个炸过 `logFn is not a function`:
422
+ * prepare 已经成功、图却一张没发出去,日志只有一行看不懂的类型错误。
423
+ */
424
+ const rawLog = opts.log;
425
+ const logFn =
426
+ typeof rawLog === 'function'
427
+ ? rawLog
428
+ : typeof rawLog?.info === 'function'
429
+ ? (m) => rawLog.info(m)
430
+ : () => {};
431
+
432
+ const buf = await fs.readFile(filePath);
433
+ if (buf.length > MEDIA_HARD_LIMIT) {
434
+ const mb = (buf.length / 1024 / 1024).toFixed(1);
435
+ throw new Error(`${filePath} 有 ${mb}MB,超过官方硬限制 200MB,分片也发不出去`);
436
+ }
437
+
438
+ const fileName = filePath.split(/[\\/]/).pop() || 'file';
439
+ /**
440
+ * 三个校验值官方都要:
441
+ * · `md5` / `sha1` 是整个文件的
442
+ * · `md5_10m` 是**前 10002432 字节**的(不是 10MB,官方写死的数字)
443
+ * 算错了服务端不报错,只是秒传判断失效,所以照官方数字来。
444
+ */
445
+ const md5 = crypto.createHash('md5').update(buf).digest('hex');
446
+ const sha1 = crypto.createHash('sha1').update(buf).digest('hex');
447
+ const md5_10m = crypto.createHash('md5').update(buf.subarray(0, MD5_10M_SIZE)).digest('hex');
448
+
449
+ const prep = await this.uploadPrepare(scene, target, {
450
+ fileType,
451
+ fileSize: buf.length,
452
+ fileName,
453
+ md5,
454
+ sha1,
455
+ md5_10m,
456
+ });
457
+ const uploadId = prep?.upload_id;
458
+ const parts = Array.isArray(prep?.parts) ? prep.parts.slice() : [];
459
+ if (!uploadId || !parts.length) {
460
+ throw new Error(`upload_prepare 没返回可用的 upload_id / parts(${JSON.stringify(prep).slice(0, 200)})`);
461
+ }
462
+ parts.sort((a, b) => Number(a.index) - Number(b.index));
463
+
464
+ /**
465
+ * 并发数:听服务端的,但不超 4。
466
+ * `retry_timeout` / `retry_delay` 也是服务端下发的,用于 40093001 重试。
467
+ */
468
+ const conf = prep.upload_config ?? {};
469
+ const concurrency = Math.max(1, Math.min(Number(conf.concurrency) || 1, 4));
470
+ const retryTimeoutMs = Math.max(1, Number(conf.retry_timeout) || 300) * 1000;
471
+ const retryDelayMs = Math.max(0, Number(conf.retry_delay) || 1) * 1000;
472
+ logFn(`${fileName} ${(buf.length / 1024 / 1024).toFixed(1)}MB · ${parts.length} 片 · 并发 ${concurrency}`);
473
+
474
+ /**
475
+ * 每片的字节区间。**按排序后的顺序累加偏移**,不靠 `index` 当数组下标——
476
+ * `index` 是服务端给的序号,正常情况下就是 0,1,2…,但拿它直接当
477
+ * `parts[index]` 用,一旦服务端给的序号不连续就会切错片(内容错位、
478
+ * 合并出来的文件是坏的,而服务端不会报错)。
479
+ */
480
+ const offsets = [];
481
+ let acc = 0;
482
+ for (const p of parts) {
483
+ const size = Number(p.block_size) || 0;
484
+ offsets.push({ part: p, start: acc, end: size ? acc + size : buf.length });
485
+ acc += size;
486
+ }
487
+
488
+ /**
489
+ * 单片的「PUT + 确认」,带 40093001 重试。
490
+ *
491
+ * `40093001`(分片转存 BDH 通道异常)是**明确可重试**的,官方给了
492
+ * `retry_timeout`;`40093002`(日配额上限)重试一万次也没用,必须
493
+ * 直接抛出去让上层告诉用户「今天发不了了」。
494
+ */
495
+ const uploadOne = async ({ part, start, end }) => {
496
+ const idx = Number(part.index);
497
+ const chunk = buf.subarray(start, end);
498
+ const chunkMd5 = crypto.createHash('md5').update(chunk).digest('hex');
499
+
500
+ let lastErr = null;
501
+ const deadline = Date.now() + retryTimeoutMs;
502
+ while (Date.now() < deadline) {
503
+ try {
504
+ await this.#putPart(part.presigned_url, chunk);
505
+ await this.uploadPartFinish(scene, target, {
506
+ uploadId,
507
+ partIndex: idx,
508
+ blockSize: chunk.length,
509
+ md5: chunkMd5,
510
+ });
511
+ return;
512
+ } catch (e) {
513
+ lastErr = e;
514
+ if (Number(e?.qqCode) === 40093002) throw e; // 日配额,重试无用
515
+ await new Promise((r) => setTimeout(r, retryDelayMs));
516
+ }
517
+ }
518
+ throw new Error(`分片 ${idx} 上传失败(重试到 retry_timeout 仍不行):${lastErr?.message ?? '未知'}`);
519
+ };
520
+
521
+ /**
522
+ * 按 concurrency 分批并发。用「一批一批」而不是「维持 N 个在飞」:
523
+ * 片数通常 3~10,分批足够,代码简单且失败位置好定位。
524
+ */
525
+ for (let i = 0; i < offsets.length; i += concurrency) {
526
+ await Promise.all(offsets.slice(i, i + concurrency).map(uploadOne));
527
+ }
528
+
529
+ // ④ 合并
530
+ return this.uploadMedia(scene, target, { fileType, uploadId, fileName, reuse: false });
531
+ }
532
+
320
533
  /** 上传后再以 msg_type=7 发出(被动回复) */
321
534
  async sendMedia(scene, target, { fileType, url, fileData, msgId, messageReference }) {
322
535
  const up = await this.uploadMedia(scene, target, { fileType, url, fileData });
package/src/ledger.js CHANGED
@@ -225,8 +225,8 @@ export class EventLedger {
225
225
  `INSERT OR IGNORE INTO events
226
226
  (id, bot_id, kind, chat_id, msg_id, user_id, user_name, content,
227
227
  at, observed_at, time_basis, dedupe,
228
- msg_idx, ref_msg_idx, union_openid, quoted, payload)
229
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
228
+ msg_idx, ref_msg_idx, union_openid, quoted, payload, trace)
229
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
230
230
  )
231
231
  .run(
232
232
  id,
@@ -246,6 +246,8 @@ export class EventLedger {
246
246
  String(msg.raw?.author?.union_openid ?? '') || null,
247
247
  String(msg.quoted ?? '') || null,
248
248
  payload,
249
+ // 链路 id:来自 buildMessage(见 src/trace.js)。老调用方没有就给空串
250
+ String(msg.traceId ?? ''),
249
251
  );
250
252
  // changes === 0 说明撞了 dedupe 唯一约束 = 官方重推的同一条消息
251
253
  if (!r?.changes) {
@@ -419,14 +421,14 @@ export class EventLedger {
419
421
  const rows = beforeSeq
420
422
  ? this.db
421
423
  .prepare(
422
- `SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted
424
+ `SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted, trace
423
425
  FROM events WHERE bot_id = ? AND chat_id = ? AND seq < ?
424
426
  ORDER BY seq DESC LIMIT ?`,
425
427
  )
426
428
  .all(this.botId, String(chatId), Number(beforeSeq), lim)
427
429
  : this.db
428
430
  .prepare(
429
- `SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted
431
+ `SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted, trace
430
432
  FROM events WHERE bot_id = ? AND chat_id = ?
431
433
  ORDER BY seq DESC LIMIT ?`,
432
434
  )
@@ -442,6 +444,8 @@ export class EventLedger {
442
444
  timeBasis: r.time_basis,
443
445
  msgIdx: r.msg_idx,
444
446
  quoted: r.quoted,
447
+ // 链路 id:调用方拿它去查"这条消息后来发生了什么"
448
+ trace: r.trace ?? '',
445
449
  content: contentOf(r),
446
450
  }));
447
451
  }
package/src/message.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { createLogger } from './logger.js';
3
- import { FILE_TYPE, MEDIA_SOFT_LIMIT } from './api.js';
3
+ import { FILE_TYPE, MEDIA_SOFT_LIMIT, MEDIA_HARD_LIMIT, DIRECT_UPLOAD_LIMIT } from './api.js';
4
4
  import { messageKindOf, EVENTS } from './events.js';
5
5
  import { renderMenuMd, mdToPlain } from './markdown.js';
6
+ import { newTraceId } from './trace.js';
6
7
 
7
8
  const log = createLogger('message');
8
9
 
@@ -210,7 +211,7 @@ export function parseElements(list, depth = 0) {
210
211
  /**
211
212
  * 把官方事件体统一成好用的 Message 对象,插件只跟它打交道。
212
213
  */
213
- export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
214
+ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceId }) {
214
215
  const kind = messageKindOf(eventName) ?? 'unknown';
215
216
 
216
217
  let groupId = '';
@@ -251,6 +252,13 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
251
252
 
252
253
  const msg = {
253
254
  id: d.id ?? '',
255
+ /**
256
+ * 链路 id:这一条消息从进站到回话的唯一标识。
257
+ *
258
+ * 生成点必须在内核——它先看到原始事件;插件只是路过,
259
+ * 让插件生成会出现「同一条消息被两个插件各生成一个 id」。
260
+ */
261
+ traceId: String(traceId || newTraceId()),
254
262
  kind,
255
263
  event: eventName,
256
264
  // 事件元信息(属于哪个 intent、中文说明),来自 src/events.js
@@ -672,29 +680,57 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
672
680
  if (scene !== 'group' && scene !== 'c2c') return null;
673
681
 
674
682
  /**
675
- * 图片超软限(20MB)自动降级为文件。
683
+ * 本地文件先看清两件事:**要不要降级**、**走直传还是分片**。
676
684
  *
677
- * 普通 base64 上传接口对图片有 20MB 上限,超了服务端直接 413。
678
- * 但文件(file_type=4)上限是 200MB,所以本地大图不该直接报错——
679
- * 改成按「文件」发出去,用户收到的是文件卡片(可下载查看)。
680
- * 官方「超软限降级为文件」的语义,落到这里实现。
685
+ * ① 软限制(图 20MB / 视频 30MB / 语音 20MB)——官方语义是
686
+ * 「超过软限制降级为文件类型上传」,所以把 `file_type` 改成 4。
687
+ * ② 直传上限 5MB——**这才是 413 的真正来源**。一张 24.4MB 的图
688
+ * base64 之后 32.5MB,网关直接回 `413 Request Entity Too Large`。
689
+ * 所以超过 5MB 一律走分片(`uploadLocalFileSharded`),
690
+ * 降级判断照旧生效(分片时 file_type 也按软限算)。
691
+ *
692
+ * 硬限制 200MB 是官方的绝对上限,到了这里连分片也救不了。
681
693
  */
682
694
  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 抛真正的错误 */
695
+ let size = 0;
696
+ if (local) {
697
+ const st = await fs.stat(source);
698
+ size = st.size;
699
+ if (size > MEDIA_HARD_LIMIT) {
700
+ throw new Error(
701
+ `${source} 有 ${(size / 1024 / 1024).toFixed(1)}MB,超过官方 200MB 硬限制`,
702
+ );
703
+ }
704
+ const soft = MEDIA_SOFT_LIMIT[fileType];
705
+ if (soft && size > soft) {
706
+ effectiveType = FILE_TYPE.FILE;
707
+ log.info(`媒体 ${(size / 1024 / 1024).toFixed(1)}MB 超过 ${Math.round(soft / 1024 / 1024)}MB 软限制,降级按文件发送`);
692
708
  }
693
709
  }
694
710
 
695
- const up = local
696
- ? await api.uploadLocalFile(scene, target, source, effectiveType)
697
- : await api.uploadMedia(scene, target, { fileType: effectiveType, url: source });
711
+ /**
712
+ * 三选一:
713
+ * 远程 URL → 平台自己去下载
714
+ * 本地小文件(≤5MB)→ 直传 base64
715
+ * 本地大文件(>5MB)→ 分片上传
716
+ */
717
+ let up;
718
+ if (!local) {
719
+ up = await api.uploadMedia(scene, target, { fileType: effectiveType, url: source });
720
+ } else if (size > DIRECT_UPLOAD_LIMIT) {
721
+ log.info(`媒体 ${(size / 1024 / 1024).toFixed(1)}MB 超过直传上限,走分片上传`);
722
+ up = await api.uploadLocalFileSharded(scene, target, source, effectiveType, {
723
+ /**
724
+ * 这里必须包一层:`log` 是 `createLogger()` 返回的**对象**,
725
+ * 而 `uploadLocalFileSharded` 要的是「一个打印函数」。
726
+ * 直接传 `{ log }` 会在分片开始前炸 `logFn is not a function`
727
+ * (2026-10-30 实际踩过,prepare 成功了、图却没发出去)。
728
+ */
729
+ log: (m) => log.info(m),
730
+ });
731
+ } else {
732
+ up = await api.uploadLocalFile(scene, target, source, effectiveType);
733
+ }
698
734
  if (!up?.file_info) throw new Error('未返回 file_info');
699
735
 
700
736
  const content = text ? String(text) : ' ';
package/src/outbound.js CHANGED
@@ -13,7 +13,7 @@ const log = createLogger('outbound');
13
13
  *
14
14
  * 1. 被动回复只有 5 分钟有效期,超时必失败 → 快过期时降级为主动消息
15
15
  * 2. 同一条消息最多被动回复 5 次(msg_seq 1–5)→ 用满后同样降级
16
- * 3. 同一会话每秒最多 5 条 → 本地令牌队列,超出的排队等
16
+ * 3. 发送接口 100 QPS(「每秒 5 条」只属于文字子频道,群聊 / 单聊没有这条)→ 本地令牌队列,超出的排队等
17
17
  * 4. 主动消息有两个维度:单关系 20/分钟 + 每群 1000/天 → 本地记账,用完直接拒绝
18
18
  * 5. 必须 WebSocket 在线才能发 → 离线时直接拒绝,不浪费一次网络请求
19
19
  * 6. 主动消息返回 304023 表示「已进入审核」 → 这不是失败,不能当错误处理
@@ -254,7 +254,8 @@ export class Outbound {
254
254
  this.isOnline = isOnline;
255
255
 
256
256
  const o = cfg.outbound ?? {};
257
- // 官方是 5 条/秒,默认压到 4 条,给其他来源留一点余量
257
+ // 官方没有「每会话每秒 N 条」这条限制(那只适用于文字子频道),接口级是 100 QPS;
258
+ // 这里压到 4 条/秒纯属自家的保守策略,给其他来源留一点余量
258
259
  this.perSec = Math.max(1, o.perSec ?? 4);
259
260
  /**
260
261
  * 主动消息额度。官方文档(`autogen/api/.../messages.post`)写的是两个维度: