qq-bot-roost 0.9.9 → 0.9.11
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 +1 -1
- package/package.json +3 -3
- package/src/api.js +45 -6
- package/src/bot.js +103 -12
- package/src/message.js +10 -0
- package/src/updater.js +9 -4
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-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.11-blueviolet" alt="版本 0.9.11">
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qq-bot-roost",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.11",
|
|
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 && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.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 && node --test test/group-name.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 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",
|
|
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 test/group-name.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",
|
package/src/api.js
CHANGED
|
@@ -284,14 +284,22 @@ export class QQApi {
|
|
|
284
284
|
const body = { file_type: fileType, srv_send_msg: false };
|
|
285
285
|
if (url) body.url = url;
|
|
286
286
|
if (fileData) body.file_data = fileData;
|
|
287
|
+
/**
|
|
288
|
+
* `file_name` **不只分片合并要**。
|
|
289
|
+
*
|
|
290
|
+
* 官方字段说明:「文件名(分片上传或者二进制上传时必填)」。
|
|
291
|
+
* 以前只在 `upload_id` 那条路带上,于是小文件走 base64 直传时是**无名文件**——
|
|
292
|
+
* QQ 客户端拿不到名字,显示出来就没有扩展名(`报告` 而不是 `报告.docx`),
|
|
293
|
+
* 对方还得自己猜怎么打开。2026-11-02 线上真踩过。
|
|
294
|
+
*
|
|
295
|
+
* URL 上传不用带:那种路径平台自己下载转存,名字从 URL 里取。
|
|
296
|
+
*/
|
|
297
|
+
if (fileName) body.file_name = fileName;
|
|
287
298
|
/**
|
|
288
299
|
* 分片合并:`upload_id` 一进,服务端就走「合并已上传分片」那条路,
|
|
289
|
-
* `url`
|
|
300
|
+
* `url` 可以为空。
|
|
290
301
|
*/
|
|
291
|
-
if (uploadId)
|
|
292
|
-
body.upload_id = uploadId;
|
|
293
|
-
if (fileName) body.file_name = fileName;
|
|
294
|
-
}
|
|
302
|
+
if (uploadId) body.upload_id = uploadId;
|
|
295
303
|
const r = await this.#request('POST', path, body);
|
|
296
304
|
|
|
297
305
|
if (cacheKey && r?.file_info) {
|
|
@@ -348,6 +356,11 @@ export class QQApi {
|
|
|
348
356
|
const up = await this.uploadMedia(scene, target, {
|
|
349
357
|
fileType,
|
|
350
358
|
fileData: buf.toString('base64'),
|
|
359
|
+
/**
|
|
360
|
+
* 二进制直传**必须带文件名**(官方字段说明:分片上传或二进制上传时必填)。
|
|
361
|
+
* 少了它 QQ 侧就是个无名文件,客户端连扩展名都显示不出来。
|
|
362
|
+
*/
|
|
363
|
+
fileName: filePath.split(/[\\/]/).pop() || 'file',
|
|
351
364
|
reuse: false,
|
|
352
365
|
});
|
|
353
366
|
if (cacheKey && up?.file_info) {
|
|
@@ -967,7 +980,21 @@ export class QQApi {
|
|
|
967
980
|
content_raw: String(p.contentRaw ?? ''),
|
|
968
981
|
};
|
|
969
982
|
if (p.streamMsgId) payload.stream_msg_id = p.streamMsgId;
|
|
970
|
-
if (p.msgId)
|
|
983
|
+
if (p.msgId) {
|
|
984
|
+
payload.msg_id = p.msgId;
|
|
985
|
+
/**
|
|
986
|
+
* `msg_seq` 必须显式给,而且**所有分片用同一个值**。
|
|
987
|
+
*
|
|
988
|
+
* 流式消息是「一条消息在长」,不是多条——官方示例里首片、续片、结束片
|
|
989
|
+
* 都是同一个 `msg_seq`。不传的话服务端按 1 处理,而我们自己的普通回复
|
|
990
|
+
* 也从 1 开始递增,于是「先流式、再发图/文件」会撞
|
|
991
|
+
* `40054005 消息被去重`。
|
|
992
|
+
*
|
|
993
|
+
* 取值由调用方先算好传进来(`replyStream` 会在首片前调 `takeSeq`)。
|
|
994
|
+
*/
|
|
995
|
+
if (p.msgSeq != null) payload.msg_seq = Number(p.msgSeq);
|
|
996
|
+
else payload.msg_seq = this.#nextSeq(p.msgId);
|
|
997
|
+
}
|
|
971
998
|
if (p.eventId) payload.event_id = p.eventId;
|
|
972
999
|
if (p.isWakeup) payload.is_wakeup = true;
|
|
973
1000
|
return this.#request('POST', `/v2/users/${enc(openid)}/stream_messages`, payload);
|
|
@@ -1198,6 +1225,18 @@ export class QQApi {
|
|
|
1198
1225
|
|
|
1199
1226
|
/* ---------------- 内部 ---------------- */
|
|
1200
1227
|
|
|
1228
|
+
/**
|
|
1229
|
+
* 先占一个 `msg_seq`,供流式消息这种「一条消息发好几次」的场景用。
|
|
1230
|
+
*
|
|
1231
|
+
* 为什么要有:流式接口不走 `#payload`,所以以前完全不占序号——首片按默认 1 算,
|
|
1232
|
+
* 而我们自己后续的普通回复也从 1 开始递增,两边撞上就是
|
|
1233
|
+
* `40054005 消息被去重`(用户那边表现为「发了但没出现」)。
|
|
1234
|
+
* 流式所有分片共用这一个值(一条消息一个序号)。
|
|
1235
|
+
*/
|
|
1236
|
+
takeSeq(msgId) {
|
|
1237
|
+
return this.#nextSeq(msgId);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1201
1240
|
#payload(opts) {
|
|
1202
1241
|
return buildSendPayload(opts, (id) => this.#nextSeq(id));
|
|
1203
1242
|
}
|
package/src/bot.js
CHANGED
|
@@ -89,6 +89,16 @@ export class BotRuntime {
|
|
|
89
89
|
* 拉不到就退回显示 openid,不影响任何功能。
|
|
90
90
|
*/
|
|
91
91
|
this.groupNames = new Map();
|
|
92
|
+
/** 同群并发补名时复用同一个请求,避免一条消息打两次接口 */
|
|
93
|
+
this.groupNameInflight = new Map();
|
|
94
|
+
/**
|
|
95
|
+
* 群名接口的**全局**并发闸(`getGroupInfo` 是 30 QPM)。
|
|
96
|
+
*
|
|
97
|
+
* 原来只在 `fetchGroupNames` 里限流,那是「每次调用」的上限:
|
|
98
|
+
* 面板批量拉一次、消息链路每条群消息又拉一次,两条并发就翻倍。
|
|
99
|
+
*/
|
|
100
|
+
this.groupNameActive = 0;
|
|
101
|
+
this.groupNameWaiters = [];
|
|
92
102
|
this.roles = new RoleStore(this.paths.roles);
|
|
93
103
|
/**
|
|
94
104
|
* 可达性:谁能私聊、哪个群能主动推。
|
|
@@ -484,20 +494,45 @@ export class BotRuntime {
|
|
|
484
494
|
* 再自己刷新一次很别扭;但也不能为了几个群名把面板卡住(接口有频控,
|
|
485
495
|
* 群多了还会串行超时)。所以给一个总超时,到点有多少用多少。
|
|
486
496
|
*
|
|
497
|
+
* **并发要限**:`getGroupInfo` 是 30 QPM。以前这里是 `allSettled` 全并行,
|
|
498
|
+
* 群多的用户一次打开面板就打 30+ 个请求,正好撞在限频上——
|
|
499
|
+
* 结果是**一个群名都拿不到**,面板继续显示一串 openid。
|
|
500
|
+
*
|
|
487
501
|
* @param {string[]} ids
|
|
488
|
-
* @param {{timeoutMs?:number}} [opts]
|
|
502
|
+
* @param {{timeoutMs?:number, concurrency?:number}} [opts]
|
|
489
503
|
*/
|
|
490
|
-
async fetchGroupNames(ids = [], { timeoutMs = 2500 } = {}) {
|
|
491
|
-
|
|
504
|
+
async fetchGroupNames(ids = [], { timeoutMs = 2500, concurrency = 4 } = {}) {
|
|
505
|
+
/** NaN / 0 / 负数都退回默认值:`Math.min(NaN, n)` 是 NaN,`Array.from({length:NaN})` 是空数组,一个群都不拉 */
|
|
506
|
+
const limit = Number.isFinite(concurrency) && concurrency >= 1 ? Math.floor(concurrency) : 4;
|
|
507
|
+
/**
|
|
508
|
+
* 不能只拉「还没有名字」的群:那样群改名后缓存永远不刷新。
|
|
509
|
+
* 统一交给 #ensureGroupName 按 10 分钟 TTL 判断——缓存新的时候它直接返回,不打接口。
|
|
510
|
+
*/
|
|
511
|
+
const todo = [...new Set(ids.filter(Boolean))];
|
|
492
512
|
if (!todo.length) return;
|
|
493
513
|
|
|
494
|
-
const
|
|
514
|
+
const queue = todo.slice();
|
|
515
|
+
/** 到点之后不再派新的:已经发出去的请求收不回来,但尾巴不能一直延长 */
|
|
516
|
+
let stopped = false;
|
|
517
|
+
const worker = async () => {
|
|
518
|
+
for (;;) {
|
|
519
|
+
if (stopped) return;
|
|
520
|
+
const id = queue.shift();
|
|
521
|
+
if (!id) return;
|
|
522
|
+
// #ensureGroupName 自己吞异常,这里再兜一层只为保险
|
|
523
|
+
await this.#ensureGroupName(id).catch(() => {});
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
const n = Math.max(1, Math.min(limit, queue.length));
|
|
527
|
+
const work = Promise.all(Array.from({ length: n }, worker));
|
|
528
|
+
|
|
495
529
|
let timer;
|
|
496
530
|
const cap = new Promise((r) => {
|
|
497
531
|
timer = setTimeout(r, timeoutMs);
|
|
498
532
|
timer.unref?.();
|
|
499
533
|
});
|
|
500
534
|
await Promise.race([work, cap]);
|
|
535
|
+
stopped = true;
|
|
501
536
|
clearTimeout(timer);
|
|
502
537
|
}
|
|
503
538
|
|
|
@@ -516,16 +551,58 @@ export class BotRuntime {
|
|
|
516
551
|
async #ensureGroupName(groupOpenid) {
|
|
517
552
|
if (!groupOpenid) return;
|
|
518
553
|
const hit = this.groupNames.get(groupOpenid);
|
|
519
|
-
|
|
554
|
+
/** ttl 由写入时决定:成功 10 分钟;11253(无权限)一天;其它失败 30 秒 */
|
|
555
|
+
if (hit && Date.now() - hit.at < (hit.ttl ?? 10 * 60_000)) return;
|
|
520
556
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
557
|
+
/**
|
|
558
|
+
* 同一条群消息可能触发两次(消息事件里一次、面板补一次),
|
|
559
|
+
* 两次都在 `await getGroupInfo` 之前查缓存 → 都判 miss → 打两次接口。
|
|
560
|
+
* 用 in-flight Map 让同 key 复用同一个 promise。
|
|
561
|
+
*/
|
|
562
|
+
const flying = this.groupNameInflight.get(groupOpenid);
|
|
563
|
+
if (flying) return flying;
|
|
564
|
+
|
|
565
|
+
const job = (async () => {
|
|
566
|
+
/** 全局闸:不管从哪条链路进来,同时在飞的群名请求不超过 4 个 */
|
|
567
|
+
await this.#groupNameSlot();
|
|
568
|
+
try {
|
|
569
|
+
const info = await this.api.getGroupInfo(groupOpenid);
|
|
570
|
+
const name = String(info?.group_name ?? info?.name ?? '').trim();
|
|
571
|
+
this.groupNames.set(groupOpenid, { name, at: Date.now(), ttl: 10 * 60_000 });
|
|
572
|
+
} catch (e) {
|
|
573
|
+
// 拉不到就算了:openid 也能认,只是没那么好认
|
|
574
|
+
/** api.js 抛的是 `qqCode`,不是 code;读错字段会让 11253 永远按 30 秒重试 */
|
|
575
|
+
const code = Number(e?.qqCode ?? e?.code ?? e?.errCode ?? e?.errcode ?? 0);
|
|
576
|
+
this.groupNames.set(groupOpenid, {
|
|
577
|
+
name: '',
|
|
578
|
+
at: Date.now(),
|
|
579
|
+
// 11253 = 应用无接口访问权限(仅白名单机器人可用),别每 30 秒重试
|
|
580
|
+
ttl: code === 11253 ? 24 * 3600_000 : 30_000,
|
|
581
|
+
});
|
|
582
|
+
} finally {
|
|
583
|
+
this.#freeGroupNameSlot();
|
|
584
|
+
this.groupNameInflight.delete(groupOpenid);
|
|
585
|
+
}
|
|
586
|
+
})();
|
|
587
|
+
|
|
588
|
+
this.groupNameInflight.set(groupOpenid, job);
|
|
589
|
+
return job;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 拿一个群名请求的名额(最多 4 个同时在飞) */
|
|
593
|
+
async #groupNameSlot() {
|
|
594
|
+
if (this.groupNameActive < 4) {
|
|
595
|
+
this.groupNameActive += 1;
|
|
596
|
+
return;
|
|
528
597
|
}
|
|
598
|
+
await new Promise((resolve) => this.groupNameWaiters.push(resolve));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#freeGroupNameSlot() {
|
|
602
|
+
const next = this.groupNameWaiters.shift();
|
|
603
|
+
// 有人排队就把名额直接转给它(active 不变),没人排队才真的空出来
|
|
604
|
+
if (next) next();
|
|
605
|
+
else this.groupNameActive = Math.max(0, this.groupNameActive - 1);
|
|
529
606
|
}
|
|
530
607
|
|
|
531
608
|
async stop() {
|
|
@@ -695,6 +772,20 @@ export class BotRuntime {
|
|
|
695
772
|
/* ---- 消息事件 ---- */
|
|
696
773
|
const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
|
|
697
774
|
|
|
775
|
+
/**
|
|
776
|
+
* 群名不在官方事件里(只有 `group_openid`),但框架有缓存。
|
|
777
|
+
*
|
|
778
|
+
* 以前只缓存不挂到消息上,ai-chat 取不到群名就回退成**发消息人的昵称**——
|
|
779
|
+
* 结果群会话的标题显示成「我的昵称」,和私聊分不开。
|
|
780
|
+
* 这里挂上缓存值,并异步补拉(不阻塞这条消息)。补拉按 TTL 去重,
|
|
781
|
+
* 所以群改名后最多 10 分钟能刷到。
|
|
782
|
+
*/
|
|
783
|
+
if (msg.kind === 'group' && msg.groupId) {
|
|
784
|
+
msg.groupName = this.groupName(msg.groupId) || '';
|
|
785
|
+
/** 每条群消息都问一次(内部按 TTL 去重):群改名后能刷到,没权限时也不会反复打接口 */
|
|
786
|
+
this.fetchGroupNames([msg.groupId]).catch(() => {});
|
|
787
|
+
}
|
|
788
|
+
|
|
698
789
|
/**
|
|
699
790
|
* 入账:**第一件要做的事**,在去重 / 白名单 / 限流 / 暂停之前。
|
|
700
791
|
*
|
package/src/message.js
CHANGED
|
@@ -812,6 +812,14 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
|
|
|
812
812
|
let index = 0;
|
|
813
813
|
let streamId = opts.streamMsgId || '';
|
|
814
814
|
const target = userId;
|
|
815
|
+
/**
|
|
816
|
+
* **一条流式消息只占一个 `msg_seq`**,所有分片共用。
|
|
817
|
+
*
|
|
818
|
+
* 这一步必须在首片之前做:流式接口不走 `#payload`,不主动占号的话
|
|
819
|
+
* 服务端按默认 1 算,而我们后续的普通回复也从 1 开始递增,
|
|
820
|
+
* 两边一撞就是 `40054005 消息被去重`(用户看到「发了但没出现」)。
|
|
821
|
+
*/
|
|
822
|
+
const seq = typeof api.takeSeq === 'function' ? api.takeSeq(msg.id) : 1;
|
|
815
823
|
|
|
816
824
|
return {
|
|
817
825
|
/** 发一片。返回服务端生成的 stream_msg_id(第一片才有) */
|
|
@@ -823,6 +831,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
|
|
|
823
831
|
mode: 'replace',
|
|
824
832
|
streamMsgId: streamId || undefined,
|
|
825
833
|
msgId: msg.id,
|
|
834
|
+
msgSeq: seq,
|
|
826
835
|
isWakeup: opts.isWakeup,
|
|
827
836
|
});
|
|
828
837
|
if (r?.id && !streamId) streamId = r.id;
|
|
@@ -837,6 +846,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
|
|
|
837
846
|
mode: 'replace',
|
|
838
847
|
streamMsgId: streamId || undefined,
|
|
839
848
|
msgId: msg.id,
|
|
849
|
+
msgSeq: seq,
|
|
840
850
|
isWakeup: opts.isWakeup,
|
|
841
851
|
});
|
|
842
852
|
},
|
package/src/updater.js
CHANGED
|
@@ -74,9 +74,9 @@ export async function currentVersion() {
|
|
|
74
74
|
*
|
|
75
75
|
* 留 2 秒容差:解压/拷贝和 stat 之间总有毫秒级误差。
|
|
76
76
|
*/
|
|
77
|
-
async function updatedButNotRestarted() {
|
|
77
|
+
async function updatedButNotRestarted(pkgPath = path.join(ROOT, 'package.json')) {
|
|
78
78
|
try {
|
|
79
|
-
const st = await fs.stat(
|
|
79
|
+
const st = await fs.stat(pkgPath);
|
|
80
80
|
const startedAt = Date.now() - process.uptime() * 1000;
|
|
81
81
|
return st.mtimeMs > startedAt + 2000;
|
|
82
82
|
} catch {
|
|
@@ -179,8 +179,13 @@ async function fetchTreeSha256(version) {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
/**
|
|
183
|
+
* @param {{pkgPath?: string}} [o] pkgPath 只给测试用:
|
|
184
|
+
* 自测原来直接改仓库里 package.json 的 mtime,两个 `npm test` 并行就会互相毒化
|
|
185
|
+
* (一个把 mtime 拨到未来、另一个正好读到,于是「正常时不提示重启」假失败)。
|
|
186
|
+
*/
|
|
187
|
+
export async function checkUpdate({ pkgPath } = {}) {
|
|
188
|
+
const needsRestart = await updatedButNotRestarted(pkgPath);
|
|
184
189
|
const r = await checkUpdateCore();
|
|
185
190
|
// 有新版本才值得多跑一次网络请求
|
|
186
191
|
if (r?.ok && r.hasUpdate) {
|