koishi-plugin-kkk 3.4.1 → 3.4.2
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/CHANGELOG.md +19 -0
- package/lib/compat/node-karin.js +34 -1
- package/lib/karin/module/utils/ErrorReport.js +148 -15
- package/package.json +1 -1
- package/src/compat/node-karin.ts +33 -1
- package/src/karin/module/utils/ErrorReport.ts +138 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 3.4.2
|
|
4
|
+
|
|
5
|
+
### 出错上报的内容更全,也更好看懂
|
|
6
|
+
|
|
7
|
+
- 上报的「来源信息」以前常常是一排「—」:推送任务的会话是合成的,没有群号这些东西。现在改用框架自己的接口补:
|
|
8
|
+
机器人名、群名(用 bot.getGuild 查出来)、用户名都会显示,卡在哪一步(阶段)也会自动标出来 —— 一眼就知道是谁在哪个群、发的什么。
|
|
9
|
+
- **顺便把配置也传上去了**,复现问题时有依据:你改过的设置、画质、发送内容、推送列表都会带过去。
|
|
10
|
+
两条硬规矩:**Cookie 一个都不传**(接口库里的 SESSDATA / bili_jct / 抖音那一串整块丢掉),凡是名字像 token / secret / password 的字段一律显示成 ***。
|
|
11
|
+
- **日志只传本插件(kkk)的行**:以前会把整台机器所有插件的日志尾巴都传上去,现在只挑 kkk 相关的,看的人不用再大海捞针。
|
|
12
|
+
|
|
13
|
+
### 机器人掉线时不再是一句英文报错
|
|
14
|
+
|
|
15
|
+
- 适配器掉线后,机器人的对象还在,但底层连接已经没了 —— 这时推送会让适配器内部炸出一句
|
|
16
|
+
TypeError: this._request is not a function,你只看到一堆英文堆栈。
|
|
17
|
+
现在会提前拦住,并说清楚:机器人 2092582839 当前不在线(status=0),这条消息发不出去;等它重新上线后下次推送会正常发出。
|
|
18
|
+
- 指定的机器人掉线时**不会偷偷换成另一个机器人发**(那样会串号),只会如实报出来。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
3
22
|
## 3.4.1
|
|
4
23
|
|
|
5
24
|
### 抖音解析失败时的提示看得懂了
|
package/lib/compat/node-karin.js
CHANGED
|
@@ -644,6 +644,29 @@ const contactGroup = (groupId) => ({ peer: groupId, guildId: groupId, isGroup: t
|
|
|
644
644
|
exports.contactGroup = contactGroup;
|
|
645
645
|
const contactFriend = (userId) => ({ peer: 'private:' + userId, userId, isGroup: false });
|
|
646
646
|
exports.contactFriend = contactFriend;
|
|
647
|
+
/**
|
|
648
|
+
* 这个机器人现在能不能发消息。
|
|
649
|
+
*
|
|
650
|
+
* 线上真实报错(B站推送):
|
|
651
|
+
*
|
|
652
|
+
* TypeError: this._request is not a function
|
|
653
|
+
* at _Internal._get (adapter-onebot/lib/index.js:115)
|
|
654
|
+
* at ... sendGroupMsg ...
|
|
655
|
+
* at async Object.sendMsg (koishi-plugin-kkk/lib/compat/node-karin.js:684)
|
|
656
|
+
*
|
|
657
|
+
* 适配器**掉线后 bot 对象仍然留在 ctx.bots 里**,但底层连接(OneBot 的 _request)已经没了,
|
|
658
|
+
* 于是 sendMessage 一路传到适配器内部才炸成一句莫名其妙的英文。
|
|
659
|
+
* 这里提前认出来,给一句人话(用户要求:日志和错误卡片要能看懂)。
|
|
660
|
+
*
|
|
661
|
+
* Koishi 的 Bot.status:0 离线 / 1 在线 / 2 连接中 / 3 断开 / 4 重连。
|
|
662
|
+
*/
|
|
663
|
+
function isBotOnline(bot) {
|
|
664
|
+
const status = bot?.status;
|
|
665
|
+
// 没有 status 字段的(老适配器 / 测试桩)不拦,免得把好机器人也挡住
|
|
666
|
+
if (typeof status !== 'number')
|
|
667
|
+
return true;
|
|
668
|
+
return status === 1 || status === 2;
|
|
669
|
+
}
|
|
647
670
|
function resolveBot(selfId) {
|
|
648
671
|
const runtime = (0, runtime_1.tryGetRuntime)();
|
|
649
672
|
if (!runtime)
|
|
@@ -652,7 +675,12 @@ function resolveBot(selfId) {
|
|
|
652
675
|
if (!bots?.length)
|
|
653
676
|
return undefined;
|
|
654
677
|
const matched = selfId ? bots.find((bot) => bot.selfId === selfId || bot.user?.id === selfId) : undefined;
|
|
655
|
-
|
|
678
|
+
// 指定了 selfId:就算它掉线也返回它 —— 不能偷偷换一个机器人发,那样会串号(调用方会报「不在线」)
|
|
679
|
+
if (matched)
|
|
680
|
+
return new KkkBot(matched);
|
|
681
|
+
// 没指定:优先挑在线的
|
|
682
|
+
const online = bots.find((bot) => isBotOnline(bot));
|
|
683
|
+
return online ? new KkkBot(online) : (bots[0] ? new KkkBot(bots[0]) : undefined);
|
|
656
684
|
}
|
|
657
685
|
function command(reg, handler, options) {
|
|
658
686
|
const registration = { reg, handler, options, order: commandOrder++ };
|
|
@@ -676,6 +704,11 @@ async function sendMsg(selfId, contact, content, _options) {
|
|
|
676
704
|
const bot = resolveBot(selfId);
|
|
677
705
|
if (!bot)
|
|
678
706
|
throw new Error('[kkk] 没有可用的机器人实例,无法发送消息');
|
|
707
|
+
const rawBot = bot.bot;
|
|
708
|
+
if (!isBotOnline(rawBot)) {
|
|
709
|
+
throw new Error('[kkk] 机器人 ' + String(selfId || rawBot?.selfId || '') + ' 当前不在线(status=' + String(rawBot?.status)
|
|
710
|
+
+ '),这条消息发不出去。等它重新上线后下次推送会正常发出;如果是长期不在线,检查一下适配器连接');
|
|
711
|
+
}
|
|
679
712
|
const channelId = peerOf(contact);
|
|
680
713
|
/** 解析结果合并转发:这条也走漏斗(karin.sendMsg 是业务代码另一条常用出口) */
|
|
681
714
|
if ((0, forward_collect_1.collectForward)(channelId, normalizeContent(content))) {
|
|
@@ -24,6 +24,7 @@ const fold_1 = require("../../../compat/fold");
|
|
|
24
24
|
const runtime_1 = require("../../../compat/runtime");
|
|
25
25
|
const qqOptions_1 = require("../../../qqOptions");
|
|
26
26
|
const root_1 = require("../../root");
|
|
27
|
+
const Config_1 = require("./Config");
|
|
27
28
|
const build_metadata_1 = require("./build-metadata");
|
|
28
29
|
/**
|
|
29
30
|
* 收集站地址:**写死,不开放配置**。
|
|
@@ -186,6 +187,108 @@ const makeReportId = () => {
|
|
|
186
187
|
const stamp = now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate()) + '-' + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds());
|
|
187
188
|
return stamp + '-' + Math.random().toString(36).slice(2, 8);
|
|
188
189
|
};
|
|
190
|
+
/**
|
|
191
|
+
* **用框架原生 API 补齐来源信息**(用户要求:上报里「来源信息」不能是一片「—」)。
|
|
192
|
+
*
|
|
193
|
+
* 以前只读 event 上的几个字段 —— 推送任务(B站推送 / 抖音推送)的 event 是**合成**的,
|
|
194
|
+
* 根本没有 channelId / guildId,于是详情页里群号、频道全是空的。
|
|
195
|
+
* 现在:
|
|
196
|
+
* - 先从 event 上取(消息场景最准);
|
|
197
|
+
* - 取不到就退回 bot 的原生字段(platform / selfId / user.name);
|
|
198
|
+
* - 群名 / 用户名尽量用 bot.getGuild() 这类**原生接口**查出来(查不到就算了,绝不让上报卡住)。
|
|
199
|
+
*/
|
|
200
|
+
const collectSource = async (input, adapterInfo) => {
|
|
201
|
+
const event = input.event ?? {};
|
|
202
|
+
const bot = event?.bot?.bot ?? event?.bot;
|
|
203
|
+
const channelId = String(event?.channelId ?? event?.contact?.peer ?? event?.guildId ?? '');
|
|
204
|
+
const guildId = String(event?.guildId ?? event?.contact?.guildId ?? channelId);
|
|
205
|
+
const userId = String(event?.userId ?? event?.sender?.userId ?? event?.author?.userId ?? '');
|
|
206
|
+
const source = {
|
|
207
|
+
platform: String(bot?.platform ?? event?.platform ?? adapterInfo?.platform ?? ''),
|
|
208
|
+
route: String(input.route ?? ''),
|
|
209
|
+
stage: String(input.stage ?? ''),
|
|
210
|
+
business: String(input.business ?? ''),
|
|
211
|
+
guildId,
|
|
212
|
+
channelId,
|
|
213
|
+
userId,
|
|
214
|
+
selfId: String(event?.selfId ?? bot?.selfId ?? bot?.account?.selfId ?? ''),
|
|
215
|
+
// 机器人名 / 用户名:能取到就填,详情页里比一串数字好认
|
|
216
|
+
botName: String(bot?.user?.name ?? bot?.username ?? bot?.account?.name ?? ''),
|
|
217
|
+
userName: String(event?.sender?.nick ?? event?.author?.nick ?? event?.username ?? event?.sender?.name ?? ''),
|
|
218
|
+
guildName: '',
|
|
219
|
+
command: (0, fold_1.foldAndTruncate)(String(event?.msg ?? event?.content ?? ''), 300)
|
|
220
|
+
};
|
|
221
|
+
// 群名(框架原生接口;取不到就留空,不抛错)
|
|
222
|
+
if (!source.guildName && channelId && typeof bot?.getGuild === 'function') {
|
|
223
|
+
try {
|
|
224
|
+
const guild = await bot.getGuild(channelId);
|
|
225
|
+
source.guildName = String(guild?.name ?? guild?.guildName ?? '');
|
|
226
|
+
}
|
|
227
|
+
catch { /* 频道查不到就算了 */ }
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 阶段(stage):解析链路里抛的错会带一句「步骤「渲染评论区」失败」,
|
|
231
|
+
* 直接抽出来当阶段,详情页上就能看出是卡在哪一步(route 同理,有就带)。
|
|
232
|
+
*/
|
|
233
|
+
if (!source.stage) {
|
|
234
|
+
const message = String(input.error?.message ?? '');
|
|
235
|
+
const step = /步骤「([^」]+)」/.exec(message) ?? /步骤失败[((]([^))]+)[))]/.exec(message);
|
|
236
|
+
if (step)
|
|
237
|
+
source.stage = step[1];
|
|
238
|
+
}
|
|
239
|
+
return source;
|
|
240
|
+
};
|
|
241
|
+
/** 上游配置的几段(与 config.json 的顶层键一致);Config 是只有 get 的 Proxy,只能按名字取 */
|
|
242
|
+
const UPSTREAM_SECTIONS = ['amagi', 'app', 'douyin', 'bilibili', 'kuaishou', 'xiaohongshu', 'pushlist'];
|
|
243
|
+
/** 这些键一律不上传(cookie / 密钥 / 密码之类) */
|
|
244
|
+
const SECRET_KEY = /cookie|token|secret|password|passwd|sessdata|bili_jct|csrf|authorization|apikey|api_key/i;
|
|
245
|
+
/**
|
|
246
|
+
* **上报用的配置快照**:把「改过什么」也带上去,复现问题才有依据。
|
|
247
|
+
*
|
|
248
|
+
* 两条硬规矩:
|
|
249
|
+
* 1. **cookie 一个都不传**(SESSDATA / bili_jct / 抖音那一长串都在 amagi.cookies 里,整块丢掉);
|
|
250
|
+
* 2. 其余凡是键名像密钥/密码的(token / secret / password / apiKey…)也一律换成 '***'。
|
|
251
|
+
*/
|
|
252
|
+
const snapshotConfig = (upstream, qq) => {
|
|
253
|
+
const clean = (value, depth) => {
|
|
254
|
+
if (depth > 6 || value === null || value === undefined)
|
|
255
|
+
return value;
|
|
256
|
+
if (Array.isArray(value))
|
|
257
|
+
return value.slice(0, 200).map((item) => clean(item, depth + 1));
|
|
258
|
+
if (typeof value !== 'object')
|
|
259
|
+
return typeof value === 'string' ? (0, fold_1.foldAndTruncate)(value, 500) : value;
|
|
260
|
+
const out = {};
|
|
261
|
+
for (const [key, item] of Object.entries(value)) {
|
|
262
|
+
if (SECRET_KEY.test(key)) {
|
|
263
|
+
out[key] = '***';
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
out[key] = clean(item, depth + 1);
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
};
|
|
270
|
+
const upstreamClean = clean(upstream ?? {}, 0);
|
|
271
|
+
// cookie 整块不要:键名可能不叫 cookie(amagi.cookies.bilibili 才是),所以直接在源头删掉
|
|
272
|
+
if (upstreamClean?.amagi && typeof upstreamClean.amagi === 'object')
|
|
273
|
+
delete upstreamClean.amagi.cookies;
|
|
274
|
+
return { qq: clean(qq, 0), upstream: upstreamClean };
|
|
275
|
+
};
|
|
276
|
+
/** 日志只留本项目(kkk)的行:日志文件是 JSON 行,控制台那份是人读的文本行,两种都要认 */
|
|
277
|
+
const isOwnLogLine = (line) => {
|
|
278
|
+
const text = String(line ?? '').trim();
|
|
279
|
+
if (!text)
|
|
280
|
+
return false;
|
|
281
|
+
if (text.startsWith('{')) {
|
|
282
|
+
try {
|
|
283
|
+
const record = JSON.parse(text);
|
|
284
|
+
const name = String(record?.name ?? '');
|
|
285
|
+
// 本插件的日志 name 是 kkk;带 paths 的是它的作用域
|
|
286
|
+
return name === 'kkk' || name === root_1.Root.pluginName || (Array.isArray(record?.meta?.paths) && record.meta.paths.includes('uclubw'));
|
|
287
|
+
}
|
|
288
|
+
catch { /* 不是完整 JSON,按文本处理 */ }
|
|
289
|
+
}
|
|
290
|
+
return /\bkkk\b/.test(text);
|
|
291
|
+
};
|
|
189
292
|
/**
|
|
190
293
|
* 上传一份错误报告。
|
|
191
294
|
*
|
|
@@ -203,22 +306,41 @@ const uploadErrorReport = async (input) => {
|
|
|
203
306
|
const id = makeReportId();
|
|
204
307
|
const lines = (input.logs ?? []).slice(-Math.max(config.logLines, 0))
|
|
205
308
|
.map((line) => (0, fold_1.foldAndTruncate)(line, LOG_LINE_LIMIT));
|
|
206
|
-
const
|
|
309
|
+
const envLogsRaw = readLogFileTail(Math.min(config.logLines, 300));
|
|
310
|
+
/** 宿主日志文件里**只留本项目(kkk)的行**(用户要求:别把整台机器所有插件的日志都传上去) */
|
|
311
|
+
const envLogs = { ...envLogsRaw, lines: envLogsRaw.lines.filter((line) => isOwnLogLine(line)) };
|
|
312
|
+
const source = await collectSource(input, adapterInfo);
|
|
313
|
+
const runtimeConfig = ((0, runtime_1.tryGetRuntime)()?.config ?? {});
|
|
314
|
+
const configSnapshot = (() => {
|
|
315
|
+
try {
|
|
316
|
+
/**
|
|
317
|
+
* 上游那份(接口库 / 各平台 / 推送列表)以 **Config** 为准:它是 config.json 的读取代理,
|
|
318
|
+
* 也是实际生效的那份;运行时 config 上的 `upstream` 只在控制台保存过才会有。
|
|
319
|
+
*
|
|
320
|
+
* 注意 Config 是个**只有 get 的 Proxy**(没有 ownKeys),直接 Object.entries 会得到空对象 ——
|
|
321
|
+
* 所以按已知的几段逐个取值再拼。
|
|
322
|
+
*/
|
|
323
|
+
const upstream = {};
|
|
324
|
+
for (const section of UPSTREAM_SECTIONS) {
|
|
325
|
+
try {
|
|
326
|
+
const value = Config_1.Config[section];
|
|
327
|
+
if (value !== undefined)
|
|
328
|
+
upstream[section] = value;
|
|
329
|
+
}
|
|
330
|
+
catch { /* 某一段读不到就跳过 */ }
|
|
331
|
+
}
|
|
332
|
+
return snapshotConfig(upstream, (0, qqOptions_1.readQqOptions)(runtimeConfig));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
node_karin_1.logger.debug('[错误上报] 配置快照生成失败(忽略): ' + String(error?.message ?? error));
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
})();
|
|
207
339
|
const payload = {
|
|
208
340
|
id,
|
|
209
341
|
time: Date.now(),
|
|
210
342
|
plugin: { name: root_1.Root.pluginName, version: root_1.Root.pluginVersion },
|
|
211
|
-
source
|
|
212
|
-
platform: String(input.event?.bot?.platform ?? input.event?.platform ?? adapterInfo?.platform ?? ''),
|
|
213
|
-
route: String(input.route ?? ''),
|
|
214
|
-
stage: String(input.stage ?? ''),
|
|
215
|
-
business: String(input.business ?? ''),
|
|
216
|
-
guildId: String(input.event?.guildId ?? ''),
|
|
217
|
-
channelId: String(input.event?.channelId ?? ''),
|
|
218
|
-
userId: String(input.event?.userId ?? ''),
|
|
219
|
-
selfId: String(input.event?.selfId ?? ''),
|
|
220
|
-
command: (0, fold_1.foldAndTruncate)(String(input.event?.msg ?? input.event?.content ?? ''), 300)
|
|
221
|
-
},
|
|
343
|
+
source,
|
|
222
344
|
error: {
|
|
223
345
|
name: String(error?.name ?? 'Error'),
|
|
224
346
|
message: (0, fold_1.foldAndTruncate)(error?.message ?? String(error), fold_1.MAX_CAPTURED_LOG_CHARS),
|
|
@@ -247,16 +369,27 @@ const uploadErrorReport = async (input) => {
|
|
|
247
369
|
// 宿主日志文件的尾部:整台机器最近的动静
|
|
248
370
|
envFile: envLogs.file ?? '',
|
|
249
371
|
envLines: envLogs.lines,
|
|
250
|
-
envTruncated: envLogs.truncated
|
|
372
|
+
envTruncated: envLogs.truncated,
|
|
373
|
+
// 只留本项目日志这件事要说清楚,免得看的人以为日志被截断了
|
|
374
|
+
envFiltered: 'kkk-only'
|
|
251
375
|
},
|
|
376
|
+
/** 配置快照:cookie 与密钥已剔除(见 snapshotConfig) */
|
|
377
|
+
config: configSnapshot,
|
|
252
378
|
extra: input.extra ?? {}
|
|
253
379
|
};
|
|
254
|
-
|
|
380
|
+
/**
|
|
381
|
+
* 体积兜底:太大就一层层砍(先去掉整份配置快照,再去掉宿主日志,最后砍本次日志)。
|
|
382
|
+
* 配置快照最占地方(几百个字段),但它对复现最有帮助,所以放在最后才丢。
|
|
383
|
+
*/
|
|
255
384
|
let body = Buffer.from(JSON.stringify(payload));
|
|
256
|
-
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
385
|
+
if (body.length > PAYLOAD_LIMIT_BYTES && payload.logs) {
|
|
257
386
|
payload.logs.envLines = [];
|
|
258
387
|
body = Buffer.from(JSON.stringify(payload));
|
|
259
388
|
}
|
|
389
|
+
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
390
|
+
payload.config = { dropped: '配置快照过大,已省略' };
|
|
391
|
+
body = Buffer.from(JSON.stringify(payload));
|
|
392
|
+
}
|
|
260
393
|
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
261
394
|
payload.logs.lines = payload.logs.lines.slice(-50);
|
|
262
395
|
payload.logs.count = payload.logs.lines.length;
|
package/package.json
CHANGED
package/src/compat/node-karin.ts
CHANGED
|
@@ -656,13 +656,40 @@ let commandOrder = 0
|
|
|
656
656
|
export const contactGroup = (groupId: string): Contact => ({ peer: groupId, guildId: groupId, isGroup: true })
|
|
657
657
|
export const contactFriend = (userId: string): Contact => ({ peer: 'private:' + userId, userId, isGroup: false })
|
|
658
658
|
|
|
659
|
+
/**
|
|
660
|
+
* 这个机器人现在能不能发消息。
|
|
661
|
+
*
|
|
662
|
+
* 线上真实报错(B站推送):
|
|
663
|
+
*
|
|
664
|
+
* TypeError: this._request is not a function
|
|
665
|
+
* at _Internal._get (adapter-onebot/lib/index.js:115)
|
|
666
|
+
* at ... sendGroupMsg ...
|
|
667
|
+
* at async Object.sendMsg (koishi-plugin-kkk/lib/compat/node-karin.js:684)
|
|
668
|
+
*
|
|
669
|
+
* 适配器**掉线后 bot 对象仍然留在 ctx.bots 里**,但底层连接(OneBot 的 _request)已经没了,
|
|
670
|
+
* 于是 sendMessage 一路传到适配器内部才炸成一句莫名其妙的英文。
|
|
671
|
+
* 这里提前认出来,给一句人话(用户要求:日志和错误卡片要能看懂)。
|
|
672
|
+
*
|
|
673
|
+
* Koishi 的 Bot.status:0 离线 / 1 在线 / 2 连接中 / 3 断开 / 4 重连。
|
|
674
|
+
*/
|
|
675
|
+
function isBotOnline (bot: any): boolean {
|
|
676
|
+
const status = bot?.status
|
|
677
|
+
// 没有 status 字段的(老适配器 / 测试桩)不拦,免得把好机器人也挡住
|
|
678
|
+
if (typeof status !== 'number') return true
|
|
679
|
+
return status === 1 || status === 2
|
|
680
|
+
}
|
|
681
|
+
|
|
659
682
|
function resolveBot (selfId?: string): KkkBot | undefined {
|
|
660
683
|
const runtime = tryGetRuntime()
|
|
661
684
|
if (!runtime) return undefined
|
|
662
685
|
const bots = runtime.ctx.bots as unknown as Bot[]
|
|
663
686
|
if (!bots?.length) return undefined
|
|
664
687
|
const matched = selfId ? bots.find((bot) => bot.selfId === selfId || bot.user?.id === selfId) : undefined
|
|
665
|
-
|
|
688
|
+
// 指定了 selfId:就算它掉线也返回它 —— 不能偷偷换一个机器人发,那样会串号(调用方会报「不在线」)
|
|
689
|
+
if (matched) return new KkkBot(matched)
|
|
690
|
+
// 没指定:优先挑在线的
|
|
691
|
+
const online = bots.find((bot) => isBotOnline(bot))
|
|
692
|
+
return online ? new KkkBot(online) : (bots[0] ? new KkkBot(bots[0]) : undefined)
|
|
666
693
|
}
|
|
667
694
|
|
|
668
695
|
function command (reg: RegExp | string, handler: (...args: any[]) => any, options?: Record<string, any>) {
|
|
@@ -688,6 +715,11 @@ function on (event: string, handler: (...args: any[]) => any) {
|
|
|
688
715
|
async function sendMsg (selfId: string, contact: Contact | string, content: any, _options?: any) {
|
|
689
716
|
const bot = resolveBot(selfId)
|
|
690
717
|
if (!bot) throw new Error('[kkk] 没有可用的机器人实例,无法发送消息')
|
|
718
|
+
const rawBot: any = (bot as any).bot
|
|
719
|
+
if (!isBotOnline(rawBot)) {
|
|
720
|
+
throw new Error('[kkk] 机器人 ' + String(selfId || rawBot?.selfId || '') + ' 当前不在线(status=' + String(rawBot?.status)
|
|
721
|
+
+ '),这条消息发不出去。等它重新上线后下次推送会正常发出;如果是长期不在线,检查一下适配器连接')
|
|
722
|
+
}
|
|
691
723
|
const channelId = peerOf(contact)
|
|
692
724
|
/** 解析结果合并转发:这条也走漏斗(karin.sendMsg 是业务代码另一条常用出口) */
|
|
693
725
|
if (collectForward(channelId, normalizeContent(content))) {
|
|
@@ -19,6 +19,7 @@ import { MAX_CAPTURED_LOG_CHARS, foldAndTruncate } from '../../../compat/fold'
|
|
|
19
19
|
import { tryGetRuntime } from '../../../compat/runtime'
|
|
20
20
|
import { readQqOptions } from '../../../qqOptions'
|
|
21
21
|
import { Root } from '../../root'
|
|
22
|
+
import { Config } from './Config'
|
|
22
23
|
import { getBuildMetadata } from './build-metadata'
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -192,6 +193,101 @@ const makeReportId = (): string => {
|
|
|
192
193
|
return stamp + '-' + Math.random().toString(36).slice(2, 8)
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/**
|
|
197
|
+
* **用框架原生 API 补齐来源信息**(用户要求:上报里「来源信息」不能是一片「—」)。
|
|
198
|
+
*
|
|
199
|
+
* 以前只读 event 上的几个字段 —— 推送任务(B站推送 / 抖音推送)的 event 是**合成**的,
|
|
200
|
+
* 根本没有 channelId / guildId,于是详情页里群号、频道全是空的。
|
|
201
|
+
* 现在:
|
|
202
|
+
* - 先从 event 上取(消息场景最准);
|
|
203
|
+
* - 取不到就退回 bot 的原生字段(platform / selfId / user.name);
|
|
204
|
+
* - 群名 / 用户名尽量用 bot.getGuild() 这类**原生接口**查出来(查不到就算了,绝不让上报卡住)。
|
|
205
|
+
*/
|
|
206
|
+
const collectSource = async (input: UploadErrorReportInput, adapterInfo: any): Promise<Record<string, any>> => {
|
|
207
|
+
const event: any = input.event ?? {}
|
|
208
|
+
const bot: any = event?.bot?.bot ?? event?.bot
|
|
209
|
+
const channelId = String(event?.channelId ?? event?.contact?.peer ?? event?.guildId ?? '')
|
|
210
|
+
const guildId = String(event?.guildId ?? event?.contact?.guildId ?? channelId)
|
|
211
|
+
const userId = String(event?.userId ?? event?.sender?.userId ?? event?.author?.userId ?? '')
|
|
212
|
+
const source: Record<string, any> = {
|
|
213
|
+
platform: String(bot?.platform ?? event?.platform ?? adapterInfo?.platform ?? ''),
|
|
214
|
+
route: String(input.route ?? ''),
|
|
215
|
+
stage: String(input.stage ?? ''),
|
|
216
|
+
business: String(input.business ?? ''),
|
|
217
|
+
guildId,
|
|
218
|
+
channelId,
|
|
219
|
+
userId,
|
|
220
|
+
selfId: String(event?.selfId ?? bot?.selfId ?? bot?.account?.selfId ?? ''),
|
|
221
|
+
// 机器人名 / 用户名:能取到就填,详情页里比一串数字好认
|
|
222
|
+
botName: String(bot?.user?.name ?? bot?.username ?? bot?.account?.name ?? ''),
|
|
223
|
+
userName: String(event?.sender?.nick ?? event?.author?.nick ?? event?.username ?? event?.sender?.name ?? ''),
|
|
224
|
+
guildName: '',
|
|
225
|
+
command: foldAndTruncate(String(event?.msg ?? event?.content ?? ''), 300)
|
|
226
|
+
}
|
|
227
|
+
// 群名(框架原生接口;取不到就留空,不抛错)
|
|
228
|
+
if (!source.guildName && channelId && typeof bot?.getGuild === 'function') {
|
|
229
|
+
try {
|
|
230
|
+
const guild: any = await bot.getGuild(channelId)
|
|
231
|
+
source.guildName = String(guild?.name ?? guild?.guildName ?? '')
|
|
232
|
+
} catch { /* 频道查不到就算了 */ }
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* 阶段(stage):解析链路里抛的错会带一句「步骤「渲染评论区」失败」,
|
|
236
|
+
* 直接抽出来当阶段,详情页上就能看出是卡在哪一步(route 同理,有就带)。
|
|
237
|
+
*/
|
|
238
|
+
if (!source.stage) {
|
|
239
|
+
const message = String((input.error as any)?.message ?? '')
|
|
240
|
+
const step = /步骤「([^」]+)」/.exec(message) ?? /步骤失败[((]([^))]+)[))]/.exec(message)
|
|
241
|
+
if (step) source.stage = step[1]
|
|
242
|
+
}
|
|
243
|
+
return source
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 上游配置的几段(与 config.json 的顶层键一致);Config 是只有 get 的 Proxy,只能按名字取 */
|
|
247
|
+
const UPSTREAM_SECTIONS = ['amagi', 'app', 'douyin', 'bilibili', 'kuaishou', 'xiaohongshu', 'pushlist']
|
|
248
|
+
|
|
249
|
+
/** 这些键一律不上传(cookie / 密钥 / 密码之类) */
|
|
250
|
+
const SECRET_KEY = /cookie|token|secret|password|passwd|sessdata|bili_jct|csrf|authorization|apikey|api_key/i
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* **上报用的配置快照**:把「改过什么」也带上去,复现问题才有依据。
|
|
254
|
+
*
|
|
255
|
+
* 两条硬规矩:
|
|
256
|
+
* 1. **cookie 一个都不传**(SESSDATA / bili_jct / 抖音那一长串都在 amagi.cookies 里,整块丢掉);
|
|
257
|
+
* 2. 其余凡是键名像密钥/密码的(token / secret / password / apiKey…)也一律换成 '***'。
|
|
258
|
+
*/
|
|
259
|
+
const snapshotConfig = (upstream: any, qq: Record<string, any>): Record<string, any> => {
|
|
260
|
+
const clean = (value: any, depth: number): any => {
|
|
261
|
+
if (depth > 6 || value === null || value === undefined) return value
|
|
262
|
+
if (Array.isArray(value)) return value.slice(0, 200).map((item) => clean(item, depth + 1))
|
|
263
|
+
if (typeof value !== 'object') return typeof value === 'string' ? foldAndTruncate(value, 500) : value
|
|
264
|
+
const out: Record<string, any> = {}
|
|
265
|
+
for (const [key, item] of Object.entries(value)) {
|
|
266
|
+
if (SECRET_KEY.test(key)) { out[key] = '***'; continue }
|
|
267
|
+
out[key] = clean(item, depth + 1)
|
|
268
|
+
}
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
271
|
+
const upstreamClean = clean(upstream ?? {}, 0) as Record<string, any>
|
|
272
|
+
// cookie 整块不要:键名可能不叫 cookie(amagi.cookies.bilibili 才是),所以直接在源头删掉
|
|
273
|
+
if (upstreamClean?.amagi && typeof upstreamClean.amagi === 'object') delete upstreamClean.amagi.cookies
|
|
274
|
+
return { qq: clean(qq, 0), upstream: upstreamClean }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 日志只留本项目(kkk)的行:日志文件是 JSON 行,控制台那份是人读的文本行,两种都要认 */
|
|
278
|
+
const isOwnLogLine = (line: string): boolean => {
|
|
279
|
+
const text = String(line ?? '').trim()
|
|
280
|
+
if (!text) return false
|
|
281
|
+
if (text.startsWith('{')) {
|
|
282
|
+
try {
|
|
283
|
+
const record = JSON.parse(text)
|
|
284
|
+
const name = String(record?.name ?? '')
|
|
285
|
+
// 本插件的日志 name 是 kkk;带 paths 的是它的作用域
|
|
286
|
+
return name === 'kkk' || name === Root.pluginName || (Array.isArray(record?.meta?.paths) && record.meta.paths.includes('uclubw'))
|
|
287
|
+
} catch { /* 不是完整 JSON,按文本处理 */ }
|
|
288
|
+
}
|
|
289
|
+
return /\bkkk\b/.test(text)
|
|
290
|
+
}
|
|
195
291
|
export interface UploadErrorReportInput {
|
|
196
292
|
error: Error
|
|
197
293
|
/** 业务名(解析 / 推送…) */
|
|
@@ -226,23 +322,39 @@ export const uploadErrorReport = async (input: UploadErrorReportInput): Promise<
|
|
|
226
322
|
const id = makeReportId()
|
|
227
323
|
const lines = (input.logs ?? []).slice(-Math.max(config.logLines, 0))
|
|
228
324
|
.map((line) => foldAndTruncate(line, LOG_LINE_LIMIT))
|
|
229
|
-
const
|
|
325
|
+
const envLogsRaw = readLogFileTail(Math.min(config.logLines, 300))
|
|
326
|
+
/** 宿主日志文件里**只留本项目(kkk)的行**(用户要求:别把整台机器所有插件的日志都传上去) */
|
|
327
|
+
const envLogs = { ...envLogsRaw, lines: envLogsRaw.lines.filter((line) => isOwnLogLine(line)) }
|
|
328
|
+
const source = await collectSource(input, adapterInfo)
|
|
329
|
+
const runtimeConfig: any = (tryGetRuntime()?.config ?? {}) as any
|
|
330
|
+
const configSnapshot = (() => {
|
|
331
|
+
try {
|
|
332
|
+
/**
|
|
333
|
+
* 上游那份(接口库 / 各平台 / 推送列表)以 **Config** 为准:它是 config.json 的读取代理,
|
|
334
|
+
* 也是实际生效的那份;运行时 config 上的 `upstream` 只在控制台保存过才会有。
|
|
335
|
+
*
|
|
336
|
+
* 注意 Config 是个**只有 get 的 Proxy**(没有 ownKeys),直接 Object.entries 会得到空对象 ——
|
|
337
|
+
* 所以按已知的几段逐个取值再拼。
|
|
338
|
+
*/
|
|
339
|
+
const upstream: Record<string, any> = {}
|
|
340
|
+
for (const section of UPSTREAM_SECTIONS) {
|
|
341
|
+
try {
|
|
342
|
+
const value = (Config as any)[section]
|
|
343
|
+
if (value !== undefined) upstream[section] = value
|
|
344
|
+
} catch { /* 某一段读不到就跳过 */ }
|
|
345
|
+
}
|
|
346
|
+
return snapshotConfig(upstream, readQqOptions(runtimeConfig))
|
|
347
|
+
} catch (error: any) {
|
|
348
|
+
logger.debug('[错误上报] 配置快照生成失败(忽略): ' + String(error?.message ?? error))
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
})()
|
|
230
352
|
|
|
231
353
|
const payload: Record<string, any> = {
|
|
232
354
|
id,
|
|
233
355
|
time: Date.now(),
|
|
234
356
|
plugin: { name: Root.pluginName, version: Root.pluginVersion },
|
|
235
|
-
source
|
|
236
|
-
platform: String(input.event?.bot?.platform ?? input.event?.platform ?? adapterInfo?.platform ?? ''),
|
|
237
|
-
route: String(input.route ?? ''),
|
|
238
|
-
stage: String(input.stage ?? ''),
|
|
239
|
-
business: String(input.business ?? ''),
|
|
240
|
-
guildId: String(input.event?.guildId ?? ''),
|
|
241
|
-
channelId: String(input.event?.channelId ?? ''),
|
|
242
|
-
userId: String(input.event?.userId ?? ''),
|
|
243
|
-
selfId: String(input.event?.selfId ?? ''),
|
|
244
|
-
command: foldAndTruncate(String(input.event?.msg ?? input.event?.content ?? ''), 300)
|
|
245
|
-
},
|
|
357
|
+
source,
|
|
246
358
|
error: {
|
|
247
359
|
name: String(error?.name ?? 'Error'),
|
|
248
360
|
message: foldAndTruncate(error?.message ?? String(error), MAX_CAPTURED_LOG_CHARS),
|
|
@@ -271,17 +383,28 @@ export const uploadErrorReport = async (input: UploadErrorReportInput): Promise<
|
|
|
271
383
|
// 宿主日志文件的尾部:整台机器最近的动静
|
|
272
384
|
envFile: envLogs.file ?? '',
|
|
273
385
|
envLines: envLogs.lines,
|
|
274
|
-
envTruncated: envLogs.truncated
|
|
386
|
+
envTruncated: envLogs.truncated,
|
|
387
|
+
// 只留本项目日志这件事要说清楚,免得看的人以为日志被截断了
|
|
388
|
+
envFiltered: 'kkk-only'
|
|
275
389
|
},
|
|
390
|
+
/** 配置快照:cookie 与密钥已剔除(见 snapshotConfig) */
|
|
391
|
+
config: configSnapshot,
|
|
276
392
|
extra: input.extra ?? {}
|
|
277
393
|
}
|
|
278
394
|
|
|
279
|
-
|
|
395
|
+
/**
|
|
396
|
+
* 体积兜底:太大就一层层砍(先去掉整份配置快照,再去掉宿主日志,最后砍本次日志)。
|
|
397
|
+
* 配置快照最占地方(几百个字段),但它对复现最有帮助,所以放在最后才丢。
|
|
398
|
+
*/
|
|
280
399
|
let body = Buffer.from(JSON.stringify(payload))
|
|
281
|
-
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
400
|
+
if (body.length > PAYLOAD_LIMIT_BYTES && payload.logs) {
|
|
282
401
|
payload.logs.envLines = []
|
|
283
402
|
body = Buffer.from(JSON.stringify(payload))
|
|
284
403
|
}
|
|
404
|
+
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
405
|
+
payload.config = { dropped: '配置快照过大,已省略' }
|
|
406
|
+
body = Buffer.from(JSON.stringify(payload))
|
|
407
|
+
}
|
|
285
408
|
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
286
409
|
payload.logs.lines = payload.logs.lines.slice(-50)
|
|
287
410
|
payload.logs.count = payload.logs.lines.length
|