qq-bot-roost 0.8.12 → 0.8.14
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 +4 -4
- package/package.json +1 -1
- package/src/api.js +18 -5
- package/src/bot.js +70 -0
- package/src/gitee.js +23 -3
- package/src/message-bus.js +1 -1
- package/src/message.js +3 -3
- package/src/outbound.js +34 -11
- package/src/paths.js +2 -0
- package/src/plugin/context.js +8 -1
- package/src/plugin/loader.js +4 -1
- package/src/plugin/market.js +107 -4
- package/src/reach.js +364 -0
- package/src/web/qq-call.js +1 -1
- package/src/web/server.js +97 -0
- package/web/app.js +171 -0
- package/web/index.html +88 -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-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.8.
|
|
13
|
+
<img src="https://img.shields.io/badge/version-0.8.14-blueviolet" alt="版本 0.8.14">
|
|
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
|
|
|
@@ -424,8 +424,8 @@ bot/
|
|
|
424
424
|
│ ├── web/server.js 面板服务端(REST + SSE)
|
|
425
425
|
│ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
|
|
426
426
|
├── test/
|
|
427
|
-
│ ├── selftest.mjs 框架自检(
|
|
428
|
-
│ ├── e2e.mjs 端到端测试(
|
|
427
|
+
│ ├── selftest.mjs 框架自检(511 项)
|
|
428
|
+
│ ├── e2e.mjs 端到端测试(185 项,起真实服务打接口)
|
|
429
429
|
│ ├── qrlogin.mjs 扫码建机器人(8 项)
|
|
430
430
|
│ └── deps.mjs 插件依赖安装(11 项)
|
|
431
431
|
├── web/ 面板前端(原生 JS/CSS,无构建)
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -10,8 +10,11 @@ const log = createLogger('api');
|
|
|
10
10
|
export const FILE_TYPE = { IMAGE: 1, VIDEO: 2, VOICE: 3, FILE: 4 };
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* 被动回复序号的**最后一道**兜底上限。
|
|
14
|
+
*
|
|
15
|
+
* 官方口径按会话类型不同:群聊 5 次(`msg_seq` 1–5),单聊 4 次(1–4)。
|
|
16
|
+
* 这里只按 `msgId` 记,拿不到会话类型,所以取两者较大的 5 当「绝不可能超过」的硬顶。
|
|
17
|
+
* 精确拦截在 `outbound.js`(`PASSIVE_MAX_REPLIES[kind]`),正常流程走不到这里。
|
|
15
18
|
*/
|
|
16
19
|
const PASSIVE_MAX_SEQ = 5;
|
|
17
20
|
|
|
@@ -164,6 +167,16 @@ export class QQApi {
|
|
|
164
167
|
const err = new Error(`HTTP ${res.status} ${method} ${path}: ${msg}`);
|
|
165
168
|
err.status = res.status;
|
|
166
169
|
err.body = data;
|
|
170
|
+
/**
|
|
171
|
+
* QQ 的业务错误码,和 HTTP 状态码是两回事。
|
|
172
|
+
*
|
|
173
|
+
* 同样是 400,`40054004`(无好友关系)和 `40034006`(内容违规)
|
|
174
|
+
* 该做的事完全不同:前者要更新可达性状态,后者只能改文案。
|
|
175
|
+
* 上层拿不到这个码就只能瞎猜,所以在这里就拆出来。
|
|
176
|
+
*
|
|
177
|
+
* 常见值见 `docs/官方接口限制.md` 与 `src/reach.js`。
|
|
178
|
+
*/
|
|
179
|
+
err.qqCode = Number(data?.code ?? data?.err_code ?? 0) || 0;
|
|
167
180
|
throw err;
|
|
168
181
|
}
|
|
169
182
|
return data;
|
|
@@ -946,14 +959,14 @@ export class QQApi {
|
|
|
946
959
|
}
|
|
947
960
|
|
|
948
961
|
/**
|
|
949
|
-
*
|
|
950
|
-
*
|
|
962
|
+
* 被动回复序号。硬顶见 `PASSIVE_MAX_SEQ`(群聊 5 / 单聊 4,这里取 5 兜底),
|
|
963
|
+
* 超了服务端返回 40034128。正常流程由 outbound.js 按会话类型先拦。
|
|
951
964
|
*/
|
|
952
965
|
#nextSeq(msgId) {
|
|
953
966
|
const cur = this.seqMap.get(msgId) ?? 0;
|
|
954
967
|
if (cur >= PASSIVE_MAX_SEQ) {
|
|
955
968
|
throw new Error(
|
|
956
|
-
`同一条消息最多回复
|
|
969
|
+
`同一条消息最多回复 5 次(群聊)/ 4 次(单聊),这条已经回了 ${cur} 次`,
|
|
957
970
|
);
|
|
958
971
|
}
|
|
959
972
|
const next = cur + 1;
|
package/src/bot.js
CHANGED
|
@@ -16,6 +16,7 @@ import { BotMetrics } from './runtime.js';
|
|
|
16
16
|
import { claimInstance, releaseInstance } from './instance.js';
|
|
17
17
|
import { botPaths, DIRS, rel } from './paths.js';
|
|
18
18
|
import { StatsStore } from './storage/index.js';
|
|
19
|
+
import { createReach } from './reach.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* 一个机器人的完整运行时。
|
|
@@ -86,6 +87,14 @@ export class BotRuntime {
|
|
|
86
87
|
*/
|
|
87
88
|
this.groupNames = new Map();
|
|
88
89
|
this.roles = new RoleStore(this.paths.roles);
|
|
90
|
+
/**
|
|
91
|
+
* 可达性:谁能私聊、哪个群能主动推。
|
|
92
|
+
*
|
|
93
|
+
* 官方没有查好友的接口,只能靠 6 个事件维护(FRIEND_ADD/DEL、
|
|
94
|
+
* C2C_MSG_REJECT/RECEIVE、GROUP_MSG_REJECT/RECEIVE)。
|
|
95
|
+
* 插件可以通过 `bot.reach` 直接问,不用自己监听一遍。
|
|
96
|
+
*/
|
|
97
|
+
this.reach = createReach({ file: this.paths.reach });
|
|
89
98
|
this.outbound = null;
|
|
90
99
|
this.session = null;
|
|
91
100
|
this.pipeline = null;
|
|
@@ -183,6 +192,7 @@ export class BotRuntime {
|
|
|
183
192
|
outbound: this.outbound,
|
|
184
193
|
session: this.session,
|
|
185
194
|
pipeline: this.pipeline,
|
|
195
|
+
reach: this.reach,
|
|
186
196
|
isOwner: (uid) => cfg.owners.includes(uid),
|
|
187
197
|
// 插件声明了 frameworkVersion 就做兼容校验;作者本地调试可以关掉
|
|
188
198
|
ignoreCompat: this.globalCfg.plugins?.ignoreFrameworkVersion === true,
|
|
@@ -344,6 +354,51 @@ export class BotRuntime {
|
|
|
344
354
|
return this.groupNames.get(groupOpenid)?.name || undefined;
|
|
345
355
|
}
|
|
346
356
|
|
|
357
|
+
/**
|
|
358
|
+
* 用 6 个事件维护可达性。
|
|
359
|
+
*
|
|
360
|
+
* 官方**没有查好友列表的接口**(sitemap 里 `friend` 只有两个事件页),
|
|
361
|
+
* 所以「接入之前就存在的关系」永远是未知的,只能维护「接入之后的变化」。
|
|
362
|
+
* 三态语义与判定顺序见 `src/reach.js` 头注释。
|
|
363
|
+
*/
|
|
364
|
+
async #noteReach(eventName, d) {
|
|
365
|
+
const openid = d?.openid ?? '';
|
|
366
|
+
switch (eventName) {
|
|
367
|
+
case 'FRIEND_ADD':
|
|
368
|
+
await this.reach.noteFriendAdd({
|
|
369
|
+
openid,
|
|
370
|
+
unionOpenid: d?.author?.union_openid ?? '',
|
|
371
|
+
scene: d?.scene ?? 0,
|
|
372
|
+
});
|
|
373
|
+
break;
|
|
374
|
+
case 'FRIEND_DEL':
|
|
375
|
+
await this.reach.noteFriendDel({ openid });
|
|
376
|
+
break;
|
|
377
|
+
case 'C2C_MSG_REJECT':
|
|
378
|
+
await this.reach.noteC2cPush({ openid, on: false });
|
|
379
|
+
break;
|
|
380
|
+
case 'C2C_MSG_RECEIVE':
|
|
381
|
+
await this.reach.noteC2cPush({ openid, on: true });
|
|
382
|
+
break;
|
|
383
|
+
case 'GROUP_MSG_REJECT':
|
|
384
|
+
await this.reach.noteGroupPush({
|
|
385
|
+
groupOpenid: d?.group_openid,
|
|
386
|
+
on: false,
|
|
387
|
+
by: d?.op_member_openid,
|
|
388
|
+
});
|
|
389
|
+
break;
|
|
390
|
+
case 'GROUP_MSG_RECEIVE':
|
|
391
|
+
await this.reach.noteGroupPush({
|
|
392
|
+
groupOpenid: d?.group_openid,
|
|
393
|
+
on: true,
|
|
394
|
+
by: d?.op_member_openid,
|
|
395
|
+
});
|
|
396
|
+
break;
|
|
397
|
+
default:
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
347
402
|
/**
|
|
348
403
|
* 补一个群的名字(给面板调的公开入口,内部走带缓存的那份)。
|
|
349
404
|
* 不会抛异常,调用方不用 try/catch。
|
|
@@ -415,6 +470,7 @@ export class BotRuntime {
|
|
|
415
470
|
await this.outbound?.flush().catch(() => {});
|
|
416
471
|
await this.groups?.flush().catch(() => {});
|
|
417
472
|
await this.roles?.flush().catch(() => {});
|
|
473
|
+
await this.reach?.flush().catch(() => {});
|
|
418
474
|
this.stats?.close();
|
|
419
475
|
if (!this.mock) await releaseInstance(this.cfg.appId).catch(() => {});
|
|
420
476
|
this.state = 'stopped';
|
|
@@ -456,6 +512,12 @@ export class BotRuntime {
|
|
|
456
512
|
await groups.onRemoved(d?.group_openid);
|
|
457
513
|
}
|
|
458
514
|
|
|
515
|
+
// 可达性:官方没有查询接口,只能靠这 6 个事件维护(见 src/reach.js)
|
|
516
|
+
// 放在插件监听器之前,插件读到的就是最新状态
|
|
517
|
+
await this.#noteReach(eventName, d).catch((e) => {
|
|
518
|
+
this.log.warn(`可达性更新失败: ${e.message}`);
|
|
519
|
+
});
|
|
520
|
+
|
|
459
521
|
const meta = EVENTS[eventName] ?? null;
|
|
460
522
|
for (const l of registry.listeners.get(eventName) ?? []) {
|
|
461
523
|
// 事件里的群:有 group_openid 就按群开关过滤,没有(如私聊事件)就放行
|
|
@@ -478,6 +540,14 @@ export class BotRuntime {
|
|
|
478
540
|
|
|
479
541
|
/* ---- 消息事件 ---- */
|
|
480
542
|
const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
|
|
543
|
+
|
|
544
|
+
// 收到私聊 = 好友关系存在的强证据。不置 c2cPush:他能发不等于他允许机器人主动推
|
|
545
|
+
if (msg.kind === 'c2c' && msg.userId) {
|
|
546
|
+
this.reach
|
|
547
|
+
.noteC2cObserved({ openid: msg.userId, unionOpenid: d?.author?.union_openid })
|
|
548
|
+
.catch(() => {});
|
|
549
|
+
}
|
|
550
|
+
|
|
481
551
|
/**
|
|
482
552
|
* 先播出去,再做业务处理。
|
|
483
553
|
*
|
package/src/gitee.js
CHANGED
|
@@ -69,7 +69,14 @@ export function giteeRawUrl(repo, ref, filePath) {
|
|
|
69
69
|
* 如 ['src/', 'package.json'];不传拉全仓
|
|
70
70
|
* @param {number} [o.timeoutMs] 单文件超时
|
|
71
71
|
* @param {(msg:string)=>void} [o.onProgress] 每拉完一个文件调一次
|
|
72
|
-
* @
|
|
72
|
+
* @param {boolean} [o.allowPartial] 有文件拉不下时**不抛错**,把失败清单放进
|
|
73
|
+
* 返回值的 `failed` 里,交给调用方用别的源补拉。默认 false(整体中止)。
|
|
74
|
+
*
|
|
75
|
+
* 为什么要这个开关:Gitee 会对个别文件返回 451(内容审查),而**同一个
|
|
76
|
+
* 文件在 GitHub 上是好的**(实测 douluo 的 content-pack.sql 等 4 个文件)。
|
|
77
|
+
* 以前只要有一个失败就整包中止,等于一个文件被屏蔽 → 整个插件装不上。
|
|
78
|
+
*
|
|
79
|
+
* @returns {Promise<{files:number, skipped:number, failed:Array<{path:string,error:string}>}>}
|
|
73
80
|
*/
|
|
74
81
|
export async function fetchGiteeTree({
|
|
75
82
|
repo,
|
|
@@ -79,6 +86,7 @@ export async function fetchGiteeTree({
|
|
|
79
86
|
only = null,
|
|
80
87
|
timeoutMs = 120000,
|
|
81
88
|
onProgress,
|
|
89
|
+
allowPartial = false,
|
|
82
90
|
}) {
|
|
83
91
|
if (!repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error(`Gitee 仓库名不合法:${repo}`);
|
|
84
92
|
if (!ref) throw new Error('Gitee 下载缺少分支/tag');
|
|
@@ -121,6 +129,7 @@ export async function fetchGiteeTree({
|
|
|
121
129
|
await fs.mkdir(destDir, { recursive: true });
|
|
122
130
|
let done = 0;
|
|
123
131
|
let failed = 0;
|
|
132
|
+
const failedList = [];
|
|
124
133
|
let cursor = 0;
|
|
125
134
|
|
|
126
135
|
async function worker() {
|
|
@@ -145,7 +154,15 @@ export async function fetchGiteeTree({
|
|
|
145
154
|
signal: AbortSignal.timeout(timeoutMs),
|
|
146
155
|
});
|
|
147
156
|
if (res.ok) break;
|
|
148
|
-
|
|
157
|
+
/**
|
|
158
|
+
* 451 = Unavailable For Legal Reasons。Gitee 会对个别文件返回它
|
|
159
|
+
* (内容审查),而同一个文件在 GitHub 上是 200。光写「HTTP 451」
|
|
160
|
+
* 没人看得懂该去换下载源,所以翻成人话。
|
|
161
|
+
*/
|
|
162
|
+
lastErr =
|
|
163
|
+
res.status === 451
|
|
164
|
+
? new Error('HTTP 451(Gitee 上这个文件不可用,多半被内容审查挡了)')
|
|
165
|
+
: new Error(`HTTP ${res.status}`);
|
|
149
166
|
if (!isRetryable(res, null)) {
|
|
150
167
|
res = null;
|
|
151
168
|
break;
|
|
@@ -169,6 +186,7 @@ export async function fetchGiteeTree({
|
|
|
169
186
|
onProgress?.(`${done}/${files.length} ${f.path}`);
|
|
170
187
|
} catch (e) {
|
|
171
188
|
failed += 1;
|
|
189
|
+
failedList.push({ path: f.path, error: e.message });
|
|
172
190
|
log.warn(`拉取失败 ${f.path}: ${e.message}`);
|
|
173
191
|
}
|
|
174
192
|
}
|
|
@@ -177,9 +195,11 @@ export async function fetchGiteeTree({
|
|
|
177
195
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, files.length) }, worker));
|
|
178
196
|
|
|
179
197
|
if (failed) {
|
|
198
|
+
// 调用方要自己补拉就不抛 —— 它补完还失败会自己报错,那时信息更全
|
|
199
|
+
if (allowPartial) return { files: done, skipped: files.length - done, failed: failedList };
|
|
180
200
|
throw new Error(`Gitee 下载 ${repo}@${ref}:${files.length} 个文件里 ${failed} 个失败,已中止`);
|
|
181
201
|
}
|
|
182
|
-
return { files: done, skipped: files.length - done };
|
|
202
|
+
return { files: done, skipped: files.length - done, failed: [] };
|
|
183
203
|
}
|
|
184
204
|
|
|
185
205
|
/**
|
package/src/message-bus.js
CHANGED
|
@@ -104,7 +104,7 @@ export function serializeMessage(botId, msg) {
|
|
|
104
104
|
mentioned: Boolean(msg.mentioned),
|
|
105
105
|
addressed: Boolean(msg.addressed),
|
|
106
106
|
isCommand: Boolean(msg.isCommand),
|
|
107
|
-
/**
|
|
107
|
+
/** 被动回复用(群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次) */
|
|
108
108
|
msgId: msg.id,
|
|
109
109
|
/** 引用回复用(message_reference.message_id) */
|
|
110
110
|
msgIdx: msg.msgIdx,
|
package/src/message.js
CHANGED
|
@@ -362,7 +362,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
// 底层发送:group 走群接口,c2c 走单聊接口。
|
|
365
|
-
// 一律经过 outbound
|
|
365
|
+
// 一律经过 outbound,由它处理被动窗口(群聊 5 分钟 / 单聊 60 分钟)、每秒限速、主动消息额度。
|
|
366
366
|
const send = (opts, passive) => {
|
|
367
367
|
if (kind !== 'group' && kind !== 'c2c') return Promise.resolve(null);
|
|
368
368
|
const body = passive ? { msgId: msg.id, ...opts } : { ...opts };
|
|
@@ -410,7 +410,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
|
|
|
410
410
|
const MAX_CHUNK = 1500; // 单条上限,超了按行切成多条
|
|
411
411
|
|
|
412
412
|
/**
|
|
413
|
-
* 被动回复(带 msg_id
|
|
413
|
+
* 被动回复(带 msg_id,群聊 5 分钟内、单聊 60 分钟内有效)。
|
|
414
414
|
* **不会立刻发出**,而是攒到本次处理结束,由 flushReplies() 合并成一条。
|
|
415
415
|
* 想立刻发用 msg.replyNow()。
|
|
416
416
|
*/
|
|
@@ -446,7 +446,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
|
|
|
446
446
|
* 引用回复:以「引用某条消息」的样式发出去(官方 `message_reference`)。
|
|
447
447
|
*
|
|
448
448
|
* 跟 `reply()` 不是一回事:
|
|
449
|
-
* - `reply()` 是**被动回复**,靠 msg_id
|
|
449
|
+
* - `reply()` 是**被动回复**,靠 msg_id,群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次
|
|
450
450
|
* - 这个是**引用**,靠 `REFIDX`,视觉上带"回复某某"的引用块
|
|
451
451
|
*
|
|
452
452
|
* 默认引用当前这条消息(用它的 `msg_idx`)。想引用别的传 `refIdx`。
|
package/src/outbound.js
CHANGED
|
@@ -20,15 +20,36 @@ const log = createLogger('outbound');
|
|
|
20
20
|
* 插件只管调 msg.reply(),剩下的交给这里。
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 被动回复窗口。**两个接口不一样,别再用一个值。**
|
|
25
|
+
*
|
|
26
|
+
* 群聊 `/v2/groups/{group_openid}/messages`:5 分钟、最多 5 次
|
|
27
|
+
* 单聊 `/v2/users/{user_openid}/messages`:**60 分钟、最多 4 次**
|
|
28
|
+
*
|
|
29
|
+
* 以前两者共用「5 分钟 / 5 次」(群聊的口径),单聊的后果是:
|
|
30
|
+
* · 5 分钟后就降级成主动消息,白吃「每个好友 1000 条/天」的额度,
|
|
31
|
+
* 而被动回复本来不占主动额度;
|
|
32
|
+
* · 第 5 次回复会被服务端拒(40034128)。
|
|
33
|
+
*
|
|
34
|
+
* 各留 15 秒给网络和服务端时钟差。
|
|
35
|
+
*/
|
|
36
|
+
const PASSIVE_WINDOW_MS = {
|
|
37
|
+
group: 5 * 60 * 1000,
|
|
38
|
+
c2c: 60 * 60 * 1000,
|
|
39
|
+
};
|
|
40
|
+
const PASSIVE_SAFE_MS = {
|
|
41
|
+
group: PASSIVE_WINDOW_MS.group - 15_000,
|
|
42
|
+
c2c: PASSIVE_WINDOW_MS.c2c - 15_000,
|
|
43
|
+
};
|
|
26
44
|
|
|
27
45
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
46
|
+
* 同一条消息最多回复次数:群聊 5、单聊 4。
|
|
47
|
+
* 超过会返回 40034128「被动回复时间或次数超限」。
|
|
30
48
|
*/
|
|
31
|
-
const PASSIVE_MAX_REPLIES =
|
|
49
|
+
const PASSIVE_MAX_REPLIES = {
|
|
50
|
+
group: 5,
|
|
51
|
+
c2c: 4,
|
|
52
|
+
};
|
|
32
53
|
|
|
33
54
|
/** 主动消息进入审核的返回码,属于正常结果 */
|
|
34
55
|
const CODE_AUDIT = 304023;
|
|
@@ -245,11 +266,12 @@ export class Outbound {
|
|
|
245
266
|
// 1) 被动窗口检查:快过期就别发请求了,直接按主动消息处理
|
|
246
267
|
if (wantPassive) {
|
|
247
268
|
const age = receivedAt ? Date.now() - receivedAt : 0;
|
|
248
|
-
|
|
269
|
+
const safeMs = PASSIVE_SAFE_MS[kind];
|
|
270
|
+
if (age > safeMs) {
|
|
249
271
|
if (!this.downgrade) {
|
|
250
272
|
this.stats.expired += 1;
|
|
251
273
|
throw new SendError(
|
|
252
|
-
`这条消息已经过去 ${Math.round(age / 1000)} 秒,超过 QQ 的
|
|
274
|
+
`这条消息已经过去 ${Math.round(age / 1000)} 秒,超过 QQ 的 ${Math.round(safeMs / 1000)} 秒被动回复窗口,回复发不出去了`,
|
|
253
275
|
'expired',
|
|
254
276
|
);
|
|
255
277
|
}
|
|
@@ -267,17 +289,18 @@ export class Outbound {
|
|
|
267
289
|
// 2) 排队 + 限速 + 额度扣减 + 实际发送
|
|
268
290
|
// 额度的「查」和「扣」都放在队列里,避免两个并发推送同时通过检查后超额
|
|
269
291
|
return this.#enqueue(chatKey, async () => {
|
|
270
|
-
// 2a)
|
|
292
|
+
// 2a) 被动回复次数上限:群聊 5 次、单聊 4 次
|
|
271
293
|
// 同一条消息被多个插件连着回、或用户一句话触发多轮时很容易撞上,
|
|
272
294
|
// 本地先拦,省得服务端返回 40034128 再兜底
|
|
273
295
|
let passiveNow = wantPassive && !active;
|
|
274
296
|
if (passiveNow) {
|
|
297
|
+
const maxReplies = PASSIVE_MAX_REPLIES[kind];
|
|
275
298
|
const used = this.passiveCount.get(body.msgId) ?? 0;
|
|
276
|
-
if (used >=
|
|
299
|
+
if (used >= maxReplies) {
|
|
277
300
|
if (!this.downgrade) {
|
|
278
301
|
this.stats.expired += 1;
|
|
279
302
|
throw new SendError(
|
|
280
|
-
`同一条消息最多回复 ${
|
|
303
|
+
`同一条消息最多回复 ${maxReplies} 次(QQ 官方限制),这条已经回满了。\n` +
|
|
281
304
|
`等用户再说一句,就能继续用被动回复。`,
|
|
282
305
|
'expired',
|
|
283
306
|
);
|
package/src/paths.js
CHANGED
|
@@ -113,6 +113,8 @@ export function botPaths(id) {
|
|
|
113
113
|
groups: path.join(base, 'groups.json'),
|
|
114
114
|
roles: path.join(base, 'roles.json'),
|
|
115
115
|
quota: path.join(base, 'quota.json'),
|
|
116
|
+
/** 可达性(好友关系 / 主动推送开关),官方无查询接口,靠事件维护 */
|
|
117
|
+
reach: path.join(base, 'reach.json'),
|
|
116
118
|
/** 统计库(SQLite,存消息计数与成员活跃,不存正文) */
|
|
117
119
|
stats: path.join(base, 'stats.db'),
|
|
118
120
|
pluginState: path.join(base, 'plugins.json'),
|
package/src/plugin/context.js
CHANGED
|
@@ -302,7 +302,7 @@ export class PluginStorage {
|
|
|
302
302
|
/**
|
|
303
303
|
* 插件上下文:插件能用的全部能力都在这里,不暴露内部实现。
|
|
304
304
|
*/
|
|
305
|
-
export function createContext({ meta, paths, registry, api, cfg, config, configStore, groups, roles, levels, outbound, session, pipeline, isOwner, pages = null, routes = null, state = null }) {
|
|
305
|
+
export function createContext({ meta, paths, registry, api, cfg, config, configStore, groups, roles, levels, outbound, session, pipeline, reach, isOwner, pages = null, routes = null, state = null }) {
|
|
306
306
|
const id = meta.id;
|
|
307
307
|
// 日志 tag 带机器人 id,多机器人下同名插件的日志不会串
|
|
308
308
|
const logger = createLogger(`plugin:${paths?.id ?? '-'}:${id}`);
|
|
@@ -343,6 +343,13 @@ export function createContext({ meta, paths, registry, api, cfg, config, configS
|
|
|
343
343
|
state: state ?? {},
|
|
344
344
|
api,
|
|
345
345
|
logger,
|
|
346
|
+
/**
|
|
347
|
+
* 可达性:`ctx.reach.canC2C(openid)` / `ctx.reach.canGroupPush(groupOpenid)`。
|
|
348
|
+
*
|
|
349
|
+
* 返回 `{ verdict: 'yes'|'no'|'unknown', reason? }`。
|
|
350
|
+
* **`unknown` 不能当 `no` 用**——未知是「不知道」,不是「不行」。
|
|
351
|
+
*/
|
|
352
|
+
reach,
|
|
346
353
|
storage: new PluginStorage(paths.pluginData(id)),
|
|
347
354
|
|
|
348
355
|
/**
|
package/src/plugin/loader.js
CHANGED
|
@@ -27,7 +27,7 @@ const log = createLogger('plugins');
|
|
|
27
27
|
* 启用状态存在 data/bots/<机器人id>/plugins.json,删掉文件夹即等于卸载。
|
|
28
28
|
*/
|
|
29
29
|
export class PluginManager {
|
|
30
|
-
constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, ignoreCompat }) {
|
|
30
|
+
constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, reach, ignoreCompat }) {
|
|
31
31
|
this.dir = pluginsDir;
|
|
32
32
|
// 该机器人的全套路径(配置/数据/日志),多机器人下互不干扰
|
|
33
33
|
this.paths = paths;
|
|
@@ -41,6 +41,8 @@ export class PluginManager {
|
|
|
41
41
|
this.outbound = outbound;
|
|
42
42
|
this.session = session;
|
|
43
43
|
this.pipeline = pipeline;
|
|
44
|
+
/** 可达性(能不能私聊/能不能往这个群主动推),见 src/reach.js */
|
|
45
|
+
this.reach = reach;
|
|
44
46
|
this.isOwner = isOwner;
|
|
45
47
|
/** 跳过插件声明的 frameworkVersion 校验(作者本地调试用) */
|
|
46
48
|
this.ignoreCompat = Boolean(ignoreCompat);
|
|
@@ -407,6 +409,7 @@ export class PluginManager {
|
|
|
407
409
|
outbound: this.outbound,
|
|
408
410
|
session: this.session,
|
|
409
411
|
pipeline: this.pipeline,
|
|
412
|
+
reach: this.reach,
|
|
410
413
|
isOwner: this.isOwner,
|
|
411
414
|
pages: this.pages,
|
|
412
415
|
routes: this.routes,
|
package/src/plugin/market.js
CHANGED
|
@@ -251,6 +251,69 @@ export async function verifyPluginDir(entry, dir) {
|
|
|
251
251
|
* @param {number} [o.timeoutMs]
|
|
252
252
|
* @param {(msg:string)=>void} [o.onRetry]
|
|
253
253
|
*/
|
|
254
|
+
/**
|
|
255
|
+
* 单个文件的 GitHub raw 地址。
|
|
256
|
+
* 走 `fetchViaMirror`,所以自定义镜像 / 代理一样生效。
|
|
257
|
+
*/
|
|
258
|
+
function githubRawUrl(repo, ref, filePath) {
|
|
259
|
+
const p = String(filePath ?? '')
|
|
260
|
+
.split('/')
|
|
261
|
+
.map((s) => encodeURIComponent(s))
|
|
262
|
+
.join('/');
|
|
263
|
+
return `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/${p}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 把 Gitee 拉不下来的那几个文件,**逐个**从 GitHub 补回来。
|
|
268
|
+
*
|
|
269
|
+
* 为什么只补这几个而不是整包重拉:Gitee 的 451 是**按文件**来的 —— douluo
|
|
270
|
+
* 那么大一个插件只有 4 个文件被挡,其余都拉成功了。为 4 个文件重拉整包
|
|
271
|
+
* (几十上百个文件)纯属浪费,国内网络下很容易再断一次。
|
|
272
|
+
*
|
|
273
|
+
* @param {Array<{path:string,error:string}>} failed fetchGiteeTree 返回的失败清单
|
|
274
|
+
* @param {(url:string, init?:object)=>Promise<Response>} [fetcher] 取数函数,默认
|
|
275
|
+
* 走镜像。**给测试注入假网络用**,正常调用不需要传。
|
|
276
|
+
* @returns {Promise<Array<{path:string,error:string}>>} 补拉后仍然失败的(空数组 = 全补齐了)
|
|
277
|
+
*
|
|
278
|
+
* @internal 导出只为可测(selftest 里用它跑假网络),不是公共 API。
|
|
279
|
+
*/
|
|
280
|
+
export async function backfillFromGitHub({
|
|
281
|
+
repo,
|
|
282
|
+
branch,
|
|
283
|
+
destDir,
|
|
284
|
+
failed,
|
|
285
|
+
timeoutMs = 120000,
|
|
286
|
+
onProgress,
|
|
287
|
+
fetcher = fetchViaMirror,
|
|
288
|
+
}) {
|
|
289
|
+
const still = [];
|
|
290
|
+
for (const item of failed) {
|
|
291
|
+
const dest = path.resolve(path.join(destDir, item.path));
|
|
292
|
+
// 清单来自服务端,仍按不可信处理,防路径穿越
|
|
293
|
+
const base = path.resolve(destDir);
|
|
294
|
+
if (dest !== base && !dest.startsWith(base + path.sep)) {
|
|
295
|
+
still.push({ path: item.path, error: '越界路径' });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const res = await fetcher(githubRawUrl(repo, branch, item.path), {
|
|
300
|
+
headers: { 'User-Agent': 'qq-bot-roost' },
|
|
301
|
+
signal: AbortSignal.timeout(Math.min(timeoutMs, 30000)),
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
304
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
305
|
+
if (!buf.length) throw new Error('内容是空的');
|
|
306
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
307
|
+
await fs.writeFile(dest, buf);
|
|
308
|
+
log.info(`已从 GitHub 补齐 ${item.path}(Gitee 上拉不到)`);
|
|
309
|
+
onProgress?.(`补齐 ${item.path}`);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
still.push({ path: item.path, error: `Gitee 失败后 GitHub 也失败:${e.message}` });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return still;
|
|
315
|
+
}
|
|
316
|
+
|
|
254
317
|
async function downloadRepoContents({
|
|
255
318
|
repo,
|
|
256
319
|
branch,
|
|
@@ -259,13 +322,53 @@ async function downloadRepoContents({
|
|
|
259
322
|
expectSha256 = null,
|
|
260
323
|
timeoutMs = 120000,
|
|
261
324
|
onRetry,
|
|
325
|
+
onProgress,
|
|
262
326
|
}) {
|
|
263
327
|
const id = currentMirror().id;
|
|
264
328
|
const token = currentMirror().giteeToken;
|
|
265
329
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Gitee 路径统一走这里:允许部分失败,再用 GitHub 把缺口补上。
|
|
332
|
+
* `allowPartial` 打开后 fetchGiteeTree 不再抛错,失败清单交给下面处理。
|
|
333
|
+
*/
|
|
334
|
+
const viaGitee = async () => {
|
|
335
|
+
const r = await fetchGiteeTree({
|
|
336
|
+
repo,
|
|
337
|
+
ref: branch,
|
|
338
|
+
destDir,
|
|
339
|
+
only,
|
|
340
|
+
timeoutMs,
|
|
341
|
+
token,
|
|
342
|
+
allowPartial: true,
|
|
343
|
+
});
|
|
344
|
+
if (!r.failed?.length) return r;
|
|
345
|
+
log.warn(
|
|
346
|
+
`Gitee 上有 ${r.failed.length} 个文件拉不到(常见原因是 451 内容审查),` +
|
|
347
|
+
`改用 GitHub 逐个补齐:${r.failed.slice(0, 3).map((f) => f.path).join('、')}` +
|
|
348
|
+
(r.failed.length > 3 ? ' 等' : ''),
|
|
349
|
+
);
|
|
350
|
+
const still = await backfillFromGitHub({
|
|
351
|
+
repo,
|
|
352
|
+
branch,
|
|
353
|
+
destDir,
|
|
354
|
+
failed: r.failed,
|
|
355
|
+
timeoutMs,
|
|
356
|
+
onProgress,
|
|
357
|
+
});
|
|
358
|
+
if (still.length) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`${still.length} 个文件在 Gitee 和 GitHub 上都拉不到:\n ` +
|
|
361
|
+
still
|
|
362
|
+
.slice(0, 5)
|
|
363
|
+
.map((f) => `${f.path}(${f.error})`)
|
|
364
|
+
.join('\n ') +
|
|
365
|
+
'\n可以在面板「下载源」里换一个镜像再试。',
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return { ...r, failed: [], backfilled: r.failed.length };
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (id === 'gitee') return viaGitee();
|
|
269
372
|
|
|
270
373
|
try {
|
|
271
374
|
return await fetchAndExtract(branchTarballUrl(repo, branch), destDir, {
|
|
@@ -280,7 +383,7 @@ async function downloadRepoContents({
|
|
|
280
383
|
log.warn(`GitHub 下载失败(${e.message}),改用 Gitee 镜像`);
|
|
281
384
|
// 清掉可能写了一半的残留,再整树重拉
|
|
282
385
|
await fs.rm(destDir, { recursive: true, force: true }).catch(() => {});
|
|
283
|
-
return
|
|
386
|
+
return viaGitee();
|
|
284
387
|
}
|
|
285
388
|
}
|
|
286
389
|
|