qq-bot-roost 0.9.3 → 0.9.6
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 +2 -2
- package/package.json +1 -1
- package/src/api.js +32 -1
- package/src/gitee.js +58 -1
- package/src/message.js +25 -3
- package/src/outbound.js +29 -1
- package/src/plugin/market.js +7 -1
- package/src/web/server.js +8 -2
- package/web/app.js +7 -1
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-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.9.
|
|
13
|
+
<img src="https://img.shields.io/badge/version-0.9.6-blueviolet" alt="版本 0.9.6">
|
|
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
|
|
|
@@ -474,7 +474,7 @@ openid 完全不同。共享 `roles.json` 等于所有权限记录全部对不
|
|
|
474
474
|
## 开发
|
|
475
475
|
|
|
476
476
|
```bash
|
|
477
|
-
npm test #
|
|
477
|
+
npm test # 609 项(selftest 590 + qrlogin 8 + deps 11)
|
|
478
478
|
npm run test:e2e # 端到端 168 项(起 mock 服务打全部接口,跑在临时目录)
|
|
479
479
|
npm run test:all # 全量约 618 项:自检 + 扫码 + 依赖 + e2e(真机驱动要真机,没配就跳过)
|
|
480
480
|
npm run check:secrets # 扫硬编码凭据(CI 也跑这个,提交前建议过一遍)
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -9,6 +9,20 @@ const log = createLogger('api');
|
|
|
9
9
|
/** 富媒体类型,对应官方 file_type */
|
|
10
10
|
export const FILE_TYPE = { IMAGE: 1, VIDEO: 2, VOICE: 3, FILE: 4 };
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 富媒体软限制(字节)。官方口径:图片 20MB / 视频 30MB / 语音 20MB / 文件 200MB;
|
|
14
|
+
* 超软限会被服务端拒(普通 base64 上传接口直接 413)。
|
|
15
|
+
*
|
|
16
|
+
* `message.js` 的 `replyMedia` 用它做「图片超 20MB 自动降级为文件」;
|
|
17
|
+
* `uploadLocalFile` 拿它当最后一道兜底(防直接调它传超大文件)。
|
|
18
|
+
*/
|
|
19
|
+
export const MEDIA_SOFT_LIMIT = {
|
|
20
|
+
[FILE_TYPE.IMAGE]: 20 * 1024 * 1024,
|
|
21
|
+
[FILE_TYPE.VIDEO]: 30 * 1024 * 1024,
|
|
22
|
+
[FILE_TYPE.VOICE]: 20 * 1024 * 1024,
|
|
23
|
+
[FILE_TYPE.FILE]: 200 * 1024 * 1024,
|
|
24
|
+
};
|
|
25
|
+
|
|
12
26
|
/**
|
|
13
27
|
* 被动回复序号的**最后一道**兜底上限。
|
|
14
28
|
*
|
|
@@ -262,11 +276,28 @@ export class QQApi {
|
|
|
262
276
|
let cacheKey = null;
|
|
263
277
|
try {
|
|
264
278
|
const st = await fs.stat(filePath);
|
|
279
|
+
/**
|
|
280
|
+
* 软限制前置校验:普通 base64 上传接口对富媒体有大小上限,
|
|
281
|
+
* 超了服务端直接 413。在这里先挡一道,报一个能看懂的错误,
|
|
282
|
+
* 别让调用方拿到「HTTP 413 ... <html>」这种垃圾信息。
|
|
283
|
+
*/
|
|
284
|
+
const limit = MEDIA_SOFT_LIMIT[fileType];
|
|
285
|
+
if (limit && st.size > limit) {
|
|
286
|
+
const mb = (st.size / 1024 / 1024).toFixed(1);
|
|
287
|
+
const cap = Math.round(limit / 1024 / 1024);
|
|
288
|
+
throw new Error(`${filePath} 有 ${mb}MB,超过 ${cap}MB 上限,发不出去(先压缩或换张小图)`);
|
|
289
|
+
}
|
|
265
290
|
cacheKey = `${scene}:${enc(target)}:${fileType}:file:${filePath}:${st.size}:${Math.floor(st.mtimeMs)}`;
|
|
266
291
|
const hit = this.mediaCache.get(cacheKey);
|
|
267
292
|
if (hit && Date.now() - hit.at < MEDIA_TTL) return hit.value;
|
|
268
293
|
if (hit) this.mediaCache.delete(cacheKey);
|
|
269
|
-
} catch {
|
|
294
|
+
} catch (e) {
|
|
295
|
+
/**
|
|
296
|
+
* stat 失败或超限都落到这里。stat 失败(文件不存在 / 没权限)时
|
|
297
|
+
* 不能当缓存命中,继续往下走让 `readFile` 抛真正的错误;
|
|
298
|
+
* 超限是**明确拒绝**,必须直接把错误往上抛,不能悄悄读文件再传一遍。
|
|
299
|
+
*/
|
|
300
|
+
if (/上限/.test(e?.message ?? '')) throw e;
|
|
270
301
|
/* stat 失败就按无缓存走,readFile 会抛真正的错误 */
|
|
271
302
|
}
|
|
272
303
|
|
package/src/gitee.js
CHANGED
|
@@ -57,6 +57,44 @@ export function giteeRawUrl(repo, ref, filePath) {
|
|
|
57
57
|
return `https://gitee.com/${repo}/raw/${encodeURIComponent(ref)}/${p}`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** blob 地址:`https://gitee.com/api/v5/repos/{repo}/git/blobs/{sha}` */
|
|
61
|
+
export function giteeBlobUrl(repo, sha) {
|
|
62
|
+
return `${API}/repos/${repo}/git/blobs/${encodeURIComponent(sha)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 用 git blob 接口取单个文件内容,绕开被内容审查挡住的 raw CDN。
|
|
67
|
+
*
|
|
68
|
+
* 背景:Gitee 的 raw 端点会对个别文件返回 451(内容审查),而**同一个
|
|
69
|
+
* blob 走 git 对象接口是 200**——raw 走的是被审查的 CDN,blob 是 git 对象
|
|
70
|
+
* 仓库,不经过那层审查(实测 douluo 的 content-pack.sql 等 4 个文件)。
|
|
71
|
+
*
|
|
72
|
+
* 前置条件:调用方必须拿到这个 blob 的 `sha`。`fetchGiteeTree` 列清单时
|
|
73
|
+
* trees API 每个条目都带 `sha`,正好够用。
|
|
74
|
+
*
|
|
75
|
+
* @param {string} repo `owner/name`
|
|
76
|
+
* @param {string} sha blob 的 40 位十六进制 sha(来自 trees 清单)
|
|
77
|
+
* @param {string} [token] Gitee 私人令牌(选填,加在 API 调用上)
|
|
78
|
+
* @param {number} [timeoutMs]
|
|
79
|
+
* @returns {Promise<Buffer>} 文件原始字节
|
|
80
|
+
*/
|
|
81
|
+
async function fetchBlob(repo, sha, token = '', timeoutMs = 120000) {
|
|
82
|
+
const q = new URLSearchParams();
|
|
83
|
+
if (token) q.set('access_token', token);
|
|
84
|
+
const url = `${giteeBlobUrl(repo, sha)}?${q}`;
|
|
85
|
+
const res = await fetch(url, {
|
|
86
|
+
headers: { 'User-Agent': UA, accept: 'application/json' },
|
|
87
|
+
signal: AbortSignal.timeout(Math.min(timeoutMs, 30000)),
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) throw new Error(`blob HTTP ${res.status}`);
|
|
90
|
+
const data = await res.json().catch(() => null);
|
|
91
|
+
const content = data?.content;
|
|
92
|
+
if (typeof content !== 'string' || !content) throw new Error('blob 接口没返回内容');
|
|
93
|
+
const buf = Buffer.from(content, data?.encoding === 'base64' ? 'base64' : 'utf8');
|
|
94
|
+
if (!buf.length) throw new Error('blob 内容是空的');
|
|
95
|
+
return buf;
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
/**
|
|
61
99
|
* 把仓库某个 ref 的内容拉到本地目录。
|
|
62
100
|
*
|
|
@@ -177,7 +215,26 @@ export async function fetchGiteeTree({
|
|
|
177
215
|
await sleep(attempt * 1000); // 1s、2s 退避
|
|
178
216
|
}
|
|
179
217
|
}
|
|
180
|
-
if (!res)
|
|
218
|
+
if (!res) {
|
|
219
|
+
// raw 全部失败(常见是 451 内容审查)。tree 条目里带了 sha,
|
|
220
|
+
// 改走 git blob 接口兜底——blob 走的是 git 对象仓库,不经过被
|
|
221
|
+
// 审查的 raw CDN,同一份内容在 GitHub 上能下、在 blob 上也能下。
|
|
222
|
+
// 只有 451 才兜底:404 是文件真没有,blob 也拿不到,直接报错。
|
|
223
|
+
if (lastErr?.message?.startsWith('HTTP 451') && f.sha) {
|
|
224
|
+
try {
|
|
225
|
+
const buf = await fetchBlob(repo, f.sha, token, timeoutMs);
|
|
226
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
227
|
+
await fs.writeFile(dest, buf);
|
|
228
|
+
done += 1;
|
|
229
|
+
onProgress?.(`${done}/${files.length} ${f.path}`);
|
|
230
|
+
log.info(`451 文件已走 blob 接口补回 ${f.path}`);
|
|
231
|
+
continue;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
throw new Error(`blob 兜底也失败:${e.message}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
throw lastErr ?? new Error('未知错误');
|
|
237
|
+
}
|
|
181
238
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
182
239
|
if (!buf.length) throw new Error('内容是空的');
|
|
183
240
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
package/src/message.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
1
2
|
import { createLogger } from './logger.js';
|
|
2
|
-
import { FILE_TYPE } from './api.js';
|
|
3
|
+
import { FILE_TYPE, MEDIA_SOFT_LIMIT } from './api.js';
|
|
3
4
|
import { messageKindOf, EVENTS } from './events.js';
|
|
4
5
|
import { renderMenuMd, mdToPlain } from './markdown.js';
|
|
5
6
|
|
|
@@ -670,9 +671,30 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
|
|
|
670
671
|
const target = kind === 'group' ? groupId : userId;
|
|
671
672
|
if (scene !== 'group' && scene !== 'c2c') return null;
|
|
672
673
|
|
|
674
|
+
/**
|
|
675
|
+
* 图片超软限(20MB)自动降级为文件。
|
|
676
|
+
*
|
|
677
|
+
* 普通 base64 上传接口对图片有 20MB 上限,超了服务端直接 413。
|
|
678
|
+
* 但文件(file_type=4)上限是 200MB,所以本地大图不该直接报错——
|
|
679
|
+
* 改成按「文件」发出去,用户收到的是文件卡片(可下载查看)。
|
|
680
|
+
* 官方「超软限降级为文件」的语义,落到这里实现。
|
|
681
|
+
*/
|
|
682
|
+
let effectiveType = fileType;
|
|
683
|
+
if (local && fileType === FILE_TYPE.IMAGE) {
|
|
684
|
+
try {
|
|
685
|
+
const st = await fs.stat(source);
|
|
686
|
+
if (st.size > MEDIA_SOFT_LIMIT[FILE_TYPE.IMAGE]) {
|
|
687
|
+
effectiveType = FILE_TYPE.FILE;
|
|
688
|
+
log.info(`图片 ${(st.size / 1024 / 1024).toFixed(1)}MB 超过 20MB 软限制,降级按文件发送`);
|
|
689
|
+
}
|
|
690
|
+
} catch {
|
|
691
|
+
/* stat 失败按原类型走,让 uploadLocalFile 抛真正的错误 */
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
673
695
|
const up = local
|
|
674
|
-
? await api.uploadLocalFile(scene, target, source,
|
|
675
|
-
: await api.uploadMedia(scene, target, { fileType, url: source });
|
|
696
|
+
? await api.uploadLocalFile(scene, target, source, effectiveType)
|
|
697
|
+
: await api.uploadMedia(scene, target, { fileType: effectiveType, url: source });
|
|
676
698
|
if (!up?.file_info) throw new Error('未返回 file_info');
|
|
677
699
|
|
|
678
700
|
const content = text ? String(text) : ' ';
|
package/src/outbound.js
CHANGED
|
@@ -211,6 +211,34 @@ class QuotaLedger {
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* 从出站报文里挑出「人看得懂的那部分」,喂给 `digestOf`。
|
|
216
|
+
*
|
|
217
|
+
* 报文不止一种形状,只认顶层 `content` 会漏掉一大半:
|
|
218
|
+
*
|
|
219
|
+
* · 纯文本 `{ content }`
|
|
220
|
+
* · markdown `{ markdown: { content } }` ← ai-chat 默认就走这条
|
|
221
|
+
* · 卡片 `{ markdown, keyboard, ark }`
|
|
222
|
+
* · 富媒体 `{ msgType: 7, media }`,正文只有一个占位空格
|
|
223
|
+
*
|
|
224
|
+
* 漏掉的后果不是发不出去(账本的状态机不看摘要),而是摘要列全变成
|
|
225
|
+
* `…(0)#da39a3ee`——所有记录长得一模一样,「人工核对」这条路等于堵死,
|
|
226
|
+
* 真出现 `unknown` 的时候只能靠猜。
|
|
227
|
+
*/
|
|
228
|
+
export function digestTextOf(payload) {
|
|
229
|
+
const p = payload ?? {};
|
|
230
|
+
const md = p.markdown;
|
|
231
|
+
const text = p.content ?? p.text ?? (typeof md === 'string' ? md : md?.content);
|
|
232
|
+
if (typeof text === 'string' && text.trim()) return text;
|
|
233
|
+
/**
|
|
234
|
+
* 没有正文的报文给个占位。一串空摘要里冒出 `[媒体]` / `[卡片]`,
|
|
235
|
+
* 至少能分辨「这条本来就没有文字」,而不是「摘要功能又坏了」。
|
|
236
|
+
*/
|
|
237
|
+
if (p.media) return '[媒体]';
|
|
238
|
+
if (p.ark) return '[卡片]';
|
|
239
|
+
return '';
|
|
240
|
+
}
|
|
241
|
+
|
|
214
242
|
export class Outbound {
|
|
215
243
|
/**
|
|
216
244
|
* @param {object} opts
|
|
@@ -296,7 +324,7 @@ export class Outbound {
|
|
|
296
324
|
chatId: opts.target,
|
|
297
325
|
replyTo: opts.payload?.msgId,
|
|
298
326
|
msgSeq: opts.payload?.msgSeq,
|
|
299
|
-
digest: digestOf(opts.payload
|
|
327
|
+
digest: digestOf(digestTextOf(opts.payload)),
|
|
300
328
|
});
|
|
301
329
|
|
|
302
330
|
if (c.action === 'reuse') {
|
package/src/plugin/market.js
CHANGED
|
@@ -505,9 +505,14 @@ export function detectUpdates(indexPlugins, localVersions = {}) {
|
|
|
505
505
|
* 每次几十秒。参考 BetterGI 的 ScriptRepoUpdater——先拉中央仓库,
|
|
506
506
|
* 再从里面逐个检出。调用方用完必须调 `cleanupRepo()`。
|
|
507
507
|
*
|
|
508
|
+
* 只更新单个插件时传 `only`(如 `['plugins/aichat/']`),就只拉那一个
|
|
509
|
+
* 插件目录。两个好处:①下载量少一半;②**不会被别的插件的坏文件连坐**——
|
|
510
|
+
* 否则某个插件在下载源上有个文件拉不动,整仓失败,更新其它插件也一起黄。
|
|
511
|
+
*
|
|
512
|
+
* @param {string[]} [only] 只拉这些路径前缀(目录以 `/` 结尾),透传给下载层
|
|
508
513
|
* @returns {Promise<{tmp:string, index:object}>}
|
|
509
514
|
*/
|
|
510
|
-
export async function syncRepo({ repo, branch = 'main', indexUrl } = {}) {
|
|
515
|
+
export async function syncRepo({ repo, branch = 'main', indexUrl, only = null } = {}) {
|
|
511
516
|
const index = await fetchIndex(indexUrl);
|
|
512
517
|
if (!repo) repo = index.repo;
|
|
513
518
|
if (!repo) throw new Error('索引里缺少 repo 字段,无法同步');
|
|
@@ -519,6 +524,7 @@ export async function syncRepo({ repo, branch = 'main', indexUrl } = {}) {
|
|
|
519
524
|
repo,
|
|
520
525
|
branch,
|
|
521
526
|
destDir: tmp,
|
|
527
|
+
only,
|
|
522
528
|
// 索引里带了校验值就验;没带会在 tarball 那边 warn 一句
|
|
523
529
|
expectSha256: index?.archive?.sha256 ?? index?.sha256 ?? null,
|
|
524
530
|
timeoutMs: 180000,
|
package/src/web/server.js
CHANGED
|
@@ -2625,8 +2625,14 @@ export async function startWebServer({
|
|
|
2625
2625
|
return json(res, 400, { error: `${id} 已经是 ${cur},没有新版` });
|
|
2626
2626
|
}
|
|
2627
2627
|
|
|
2628
|
-
//
|
|
2629
|
-
|
|
2628
|
+
// 拉一次仓库,只更新指定的这一个;only 让它只下这个插件的目录,
|
|
2629
|
+
// 既省流量,又不会被其它插件的坏文件连坐(见 syncRepo 注释)
|
|
2630
|
+
repo = await syncRepo({
|
|
2631
|
+
repo: idx.repo,
|
|
2632
|
+
branch: idx.branch || 'main',
|
|
2633
|
+
indexUrl: src,
|
|
2634
|
+
only: [`plugins/${id}/`],
|
|
2635
|
+
});
|
|
2630
2636
|
const r = await updatePlugins({
|
|
2631
2637
|
tmp: repo.tmp,
|
|
2632
2638
|
index: repo.index,
|
package/web/app.js
CHANGED
|
@@ -3095,11 +3095,17 @@ function renderDelivery(d) {
|
|
|
3095
3095
|
const st = DELIVERY_LABEL[x.state] ?? { text: x.state, tip: '' };
|
|
3096
3096
|
const when = x.created_at ? new Date(Number(x.created_at)).toLocaleString('zh-CN', { hour12: false }) : '-';
|
|
3097
3097
|
const warn = x.state === 'unknown' || x.state === 'failed';
|
|
3098
|
+
/**
|
|
3099
|
+
* 摘要空的老记录(修 `digestTextOf` 之前发的)一律是 `…(0)#da39a3ee`,
|
|
3100
|
+
* 一列看下来完全分不清谁是谁。显示成一句人话,原文挂在 title 上备查。
|
|
3101
|
+
*/
|
|
3102
|
+
const digest = String(x.digest ?? '');
|
|
3103
|
+
const digestText = /^…\(0\)#/.test(digest) ? '(无正文)' : digest.slice(0, 60);
|
|
3098
3104
|
return `<tr>
|
|
3099
3105
|
<td>${escapeHtml(when)}</td>
|
|
3100
3106
|
<td class="mono">${escapeHtml(String(x.chat_id ?? '').slice(0, 12))}</td>
|
|
3101
3107
|
<td${warn ? ' style="color:var(--warn-text)"' : ''} title="${escapeHtml(st.tip)}">${escapeHtml(st.text)}</td>
|
|
3102
|
-
<td
|
|
3108
|
+
<td title="${escapeHtml(digest)}">${escapeHtml(digestText)}</td>
|
|
3103
3109
|
<td class="num">${Number(x.attempts ?? 0)}</td>
|
|
3104
3110
|
<td class="mono">${escapeHtml(String(x.code ?? ''))}</td>
|
|
3105
3111
|
</tr>`;
|