qq-bot-roost 0.8.12 → 0.8.13

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
@@ -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-600%2B%20passed-brightgreen" alt="测试 600+ 项">
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.13-blueviolet" alt="版本 0.8.13">
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.8.12",
3
+ "version": "0.8.13",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
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
  /**
@@ -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