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 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-600%2B%20passed-brightgreen" alt="测试 600+ 项">
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.12-blueviolet" alt="版本 0.8.12">
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 框架自检(431 项)
428
- │ ├── e2e.mjs 端到端测试(168 项,起真实服务打接口)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.12",
3
+ "version": "0.8.14",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
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
- * 官方硬限制:同一条消息最多被动回复 5 次(`msg_seq` 取 1–5)。
14
- * 第 6 次服务端返回 40034128「被动回复时间或次数超限」。
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
- * 被动回复序号。官方硬限制:**同一条消息最多回复 5 次**(msg_seq 取 1–5),
950
- * 第 6 次返回 40034128。这里兜一道,正常流程由 outbound.js 先拦。
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
- `同一条消息最多回复 ${PASSIVE_MAX_SEQ} 次(QQ 官方限制),这条已经回了 ${cur} 次`,
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
- * @returns {Promise<{files:number, skipped:number}>}
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
- lastErr = new Error(`HTTP ${res.status}`);
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
  /**
@@ -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
- /** 被动回复用(5 分钟窗口,同一条最多 5 次) */
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,由它处理 5 分钟窗口、每秒限速、主动消息额度。
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,5 分钟内有效)。
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,5 分钟内、同一条最多 5 次
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
- /** 官方给的有效期是 5 分钟,留 15 秒给网络和服务端时钟差 */
24
- const PASSIVE_WINDOW_MS = 5 * 60 * 1000;
25
- const PASSIVE_SAFE_MS = PASSIVE_WINDOW_MS - 15_000;
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
- * 官方硬限制:同一条消息最多回复 5 次(`msg_seq` 取 1–5)。
29
- * 第 6 次服务端会返回 40034128「被动回复时间或次数超限」。
46
+ * 同一条消息最多回复次数:群聊 5、单聊 4。
47
+ * 超过会返回 40034128「被动回复时间或次数超限」。
30
48
  */
31
- const PASSIVE_MAX_REPLIES = 5;
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
- if (age > PASSIVE_SAFE_MS) {
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 的 5 分钟被动回复窗口,回复发不出去了`,
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) 被动回复次数上限:官方「每个消息最多回复 5 次」
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 >= PASSIVE_MAX_REPLIES) {
299
+ if (used >= maxReplies) {
277
300
  if (!this.downgrade) {
278
301
  this.stats.expired += 1;
279
302
  throw new SendError(
280
- `同一条消息最多回复 ${PASSIVE_MAX_REPLIES} 次(QQ 官方限制),这条已经回满了。\n` +
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'),
@@ -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
  /**
@@ -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,
@@ -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
- if (id === 'gitee') {
267
- return fetchGiteeTree({ repo, ref: branch, destDir, only, timeoutMs, token });
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 fetchGiteeTree({ repo, ref: branch, destDir, only, timeoutMs, token });
386
+ return viaGitee();
284
387
  }
285
388
  }
286
389