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 +7 -6
- package/package.json +5 -4
- package/src/api.js +229 -16
- package/src/ledger.js +8 -4
- package/src/message.js +55 -19
- package/src/outbound.js +3 -2
- package/src/plugin/loader.js +77 -0
- package/src/plugin/manifest.js +149 -0
- package/src/plugin/market.js +70 -3
- package/src/plugin/protocol.js +2 -0
- package/src/storage/events-schema.js +29 -1
- package/src/tarball.js +32 -4
- package/src/trace.js +28 -0
- package/src/trust.js +264 -0
- package/src/updater.js +58 -8
- package/src/web/server.js +55 -4
- package/web/app.js +45 -0
- package/web/index.html +18 -0
- package/web/style.css +9 -0
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-
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
-
|
|
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 #
|
|
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.
|
|
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
|
-
*
|
|
15
|
+
* 超软限**降级为文件**(file_type=4),超硬限制(200MB)直接报错。
|
|
15
16
|
*
|
|
16
|
-
* `message.js` 的 `replyMedia`
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
323
|
+
* 直传上限前置校验:超过 5MB 的 base64 请求体,网关会直接回
|
|
324
|
+
* `413 Request Entity Too Large`(HTML,连业务错误码都没有)。
|
|
325
|
+
* 在这里挡一道,报一个能看懂的错误。
|
|
283
326
|
*/
|
|
284
|
-
|
|
285
|
-
if (limit && st.size > limit) {
|
|
327
|
+
if (st.size > DIRECT_UPLOAD_LIMIT) {
|
|
286
328
|
const mb = (st.size / 1024 / 1024).toFixed(1);
|
|
287
|
-
|
|
288
|
-
|
|
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 (
|
|
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
|
-
*
|
|
683
|
+
* 本地文件先看清两件事:**要不要降级**、**走直传还是分片**。
|
|
676
684
|
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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.
|
|
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
|
-
//
|
|
257
|
+
// 官方没有「每会话每秒 N 条」这条限制(那只适用于文字子频道),接口级是 100 QPS;
|
|
258
|
+
// 这里压到 4 条/秒纯属自家的保守策略,给其他来源留一点余量
|
|
258
259
|
this.perSec = Math.max(1, o.perSec ?? 4);
|
|
259
260
|
/**
|
|
260
261
|
* 主动消息额度。官方文档(`autogen/api/.../messages.post`)写的是两个维度:
|