import util from 'node:util' import { adapterCardInfo } from '../../../../compat/adapter-info' import { MAX_CARD_LOG_LINES, foldLongRuns, truncateWithNote } from '../../../../compat/fold' import { groupLinkOf, reportConfig } from '../ErrorReport' import { resolveFrameLogo } from '@/module/utils/Render' import { formatBuildTime, Render, Root } from '@/module' import { AmagiError } from '@/module/utils/amagiClient' import type { ErrorContext, RenderErrorOptions } from './types' /** 响应体原文的印字上限:反爬页可能是整份 HTML,全印会把图撑到几十屏 */ const RAW_TEXT_LIMIT = 500 /** * 取响应体原文。 * * **只收字符串 body** —— 那正是 judge 判 `ANTIBOT_PAGE` 的情形(反爬页、Argus * 拦截)。这种响应下 `error.message` 必然是 amagi 的兜底文案「平台返回了反爬页面」, * 因为 amagi 的 `extractPlatformMessage` 只认对象、拿字符串 body 一律返回 * `undefined`;平台究竟说了什么,只有这里能看到。 * * 对象 body 不收:那份信息已由 `platformCode` 与 `trace` 覆盖,整份 JSON 印进图里 * 既撑爆版面、又可能带出令牌 —— 这张图是要发到群里的。 * * amagi 只在 `createClient({ debug: true })` 时填 `error.raw`,本封装层常开着。 * @param error - amagi 失败异常 * @returns 截断后的响应体原文;body 不是字符串时为 `undefined` */ const rawTextOf = (error: AmagiError): string | undefined => { const raw = error.rawError.raw if (typeof raw !== 'string' || raw.trim() === '') return undefined return raw.length > RAW_TEXT_LIMIT ? `${raw.slice(0, RAW_TEXT_LIMIT)}…(共 ${raw.length} 字符)` : raw } /** * 把 amagi 的 v7 错误摊成模板能直接印的一块。 * * 非 amagi 异常返回 `undefined` —— 模板据此整块不渲染,而不是印一排空值。 * @param error - 捕获的异常 * @returns v7 分层错误信息,或 `undefined` */ const amagiDetailOf = (error: Error) => { if (!(error instanceof AmagiError)) return undefined const { meta } = error.envelope return { kind: error.kind, code: error.amagiCode, reason: error.reason, raw: rawTextOf(error), retryable: error.retryable, platformCode: error.rawError.platform?.code, httpStatus: error.httpStatus, requestId: meta?.requestId, attempts: meta?.attempts, durationMs: meta?.durationMs, issues: error.issues, // 逐个请求的明细。amagi 只在 debug: true 时填,封装层常开着 trace: meta?.trace } } /** * 取 amagi 失败信封里记的平台名。 * @param error - 捕获的异常 * @returns 平台名,非 amagi 异常时为 `undefined` */ const amagiPlatformOf = (error: Error): RenderErrorOptions['platform'] | undefined => { if (!(error instanceof AmagiError)) return undefined return error.envelope.meta?.platform } /** * 自有属性的转储(`util.inspect`,带 ANSI)。 * * 三样东西不进转储:`stack`(调用帧在上面的「错误堆栈」一节已经有结构高亮的那份)、 * `message` 与 `name`(栈首行就是 `名字: message`)。剩下的是调用点或上游挂上的 * 数据:Node 的 `errno` / `syscall`、三方库的响应体、OneBot 的 `cause`(`retcode` * / `wording`)—— 这些堆栈里一个字都没有,是转储存在的全部理由。 * * 一个都不剩就返回 `undefined`:引擎抛的 `TypeError: Cannot read properties of * null` 只有 message,再来一块空转储没有意义。 * * `cause` 链原样保留 —— 它的帧在上层栈里恰好被 `... N lines matching cause stack * trace ...` 省略掉了,属于补充而非重复。 * @param error - 捕获的异常 * @returns 供模板渲染的转储文本,没有额外信息时为 `undefined` */ const dumpOf = (error: Error): string | undefined => { const source = error as unknown as Record const skip = new Set(['stack', 'message', 'name']) const own: Record = {} for (const key of Object.getOwnPropertyNames(error)) { if (skip.has(key)) continue try { own[key] = source[key] } catch { // 取值就抛的 getter(罕见)跳过,别让转储把渲染带崩 } } if (Object.keys(own).length === 0) return undefined return ( util .inspect(own, { depth: 10, colors: true, breakLength: 120, showHidden: true }) // oxlint-disable-next-line no-control-regex .replace(/\x1b\[90m/g, '\x1b[90;2m') // oxlint-disable-next-line no-control-regex .replace(/\x1b\[32m/g, '\x1b[31m') ) } /** * 错误堆栈与对象转储:调用帧一律走纯文本,交给模板按结构上色。 * * 对 `AmagiError` 做 `util.inspect(error, { depth: 10, showHidden: true })` 会把 * 同一份数据打印四遍 —— message 一遍、`showHidden` 把 message 当自有属性再打一遍 * (连 ANSI 转义都成了字面量)、`rawError` 一遍、`envelope` 又一遍,实测 118 行里 * 只有 7 行是调用帧,`trace` 里那条上百字符的签名 URL 出现两次。这些字段现在由 * 「解析库错误诊断」那一节单独渲染,堆栈只需要回答「从哪儿抛的」。 * * 非 amagi 异常同样只把帧交给堆栈一节:`error.stack` 本身就是纯文本,模板据此按 * 结构上色(整对象转储一并塞进去的话,那些帧会掉进 ANSI 解析器里渲染成一片单色), * 自有属性另走 {@link dumpOf} —— 上游协议实现那一层(OneBot 的 retcode/wording、 * Node 的 errno/syscall)没有调用栈,只能靠它呈现。 * @param error - 捕获的异常 * @param override - 调用方显式指定的堆栈文本 * @returns 纯文本堆栈;非 amagi 异常且确有 message / name 之外的自有属性时,附带转储 */ /** * 堆栈和转储都**截断**。 * * 实测:不截断时错误卡片能渲染成 2880×40000(45MB)—— 光渲染要 20 秒, * 而这么大的图 QQ 必然拒收,等于「报错本身也发不出来」,用户什么都看不到。 * 卡片只需要让人一眼看出错在哪,前几千字符足够,完整内容仍在日志里。 */ const MAX_STACK_CHARS = 4000 const MAX_DUMP_CHARS = 4000 /** 错误标题那一行同样要限长(见 foldBlobs 的说明) */ const MAX_MESSAGE_CHARS = 1200 const truncateText = truncateWithNote /** * **把错误文本里的超长 base64 / 长串折叠掉**。 * * 实测:OneBot 发送失败时,适配器会把**整个请求参数**拼进 message —— * * Error with request send_group_msg, args: {"group_id":1050229473,"message":[{"type":"image","data":{"file":"base64://<4.6MB>"}}]}, retcode: 1200 * * 而错误卡片的「错误信息」一行以前是**不截断**的,于是报错本身渲染成 1440×20000、10.8MB 的图 * (光渲染要 28 秒,而且这么大的图 QQ 照样拒收 —— 等于「报错也发不出来」)。 * * 卡片只需要让人看出错在哪:base64 折成 `base64://…(省略 N 字符)`,完整内容仍在日志里。 * 注意先折叠**再**截断,否则前 4000 个字符全是 base64,调用栈反而被挤没了。 */ const foldBlobs = foldLongRuns const stackPartsOf = (error: Error, override?: string): { stack: string; dump?: string } => { if (override) return { stack: truncateText(foldBlobs(override), MAX_STACK_CHARS) } if (error instanceof AmagiError) return { stack: truncateText(foldBlobs(error.stack ?? error.message), MAX_STACK_CHARS) } return { stack: truncateText(foldBlobs(error.stack ?? error.message), MAX_STACK_CHARS), dump: truncateText(foldBlobs(dumpOf(error)), MAX_DUMP_CHARS) } } /** * 渲染错误图片 * * @param ctx - 错误处理上下文 * @param opts - 渲染选项,可覆盖默认值 * @returns 渲染后的图片元素数组 * * @remarks * 使用 `other/handlerError` 模板渲染错误信息图片, * 包含错误详情、日志、触发命令、版本信息等 * * @example * ```ts * const img = await renderErrorImage(ctx, { * platform: 'bilibili', * errorName: 'RiskControl', * errorMessage: '风控验证' * }) * await event.reply(img) * ``` */ export const renderErrorImage = async (ctx: ErrorContext, opts: RenderErrorOptions = {}) => { const { error, options, logs, event, buildMetadata } = ctx const amagi = amagiDetailOf(error) const { stack, dump } = stackPartsOf(error, opts.stack) return Render(event, 'other/handlerError', { type: 'business_error', // amagi 的失败信封自带 meta.platform,比调用点顺手传的更准; // 小红书的报错原先一律落到 system,就是因为这里没人传 platform: opts.platform ?? amagiPlatformOf(error) ?? 'system', error: { // 这几个字段模板里会直接做字符串处理(例如 version.startsWith), // 取不到值时给空串,避免错误卡片自己再崩一次 // 一定要先折叠超长 base64 再截断(OneBot 会把整个请求参数塞进 message) message: truncateText(foldBlobs(opts.errorMessage || error?.message || '未知错误'), MAX_MESSAGE_CHARS), name: String(opts.errorName || error?.name || 'Error'), stack: stack ?? '', dump: dump ?? '', businessName: options?.businessName ?? '' }, amagi, method: options.businessName, timestamp: new Date().toISOString(), /** * 卡片上只留最近这些行,**并且逐行折叠 + 截断**。 * * compat/logger 在收集阶段已经折过一遍,这里再兜一次底:这条数据还可能来自 * 别的调用方(比如自定义错误处理器直接把原始日志数组塞进来),线上那次 * 「4.6MB base64 撑出 9MB 卡片」就是从这种没折过的路径进来的。 */ logs: logs ? logs.slice(-MAX_CARD_LOG_LINES).reverse().map((entry) => ({ ...entry, message: truncateWithNote(foldLongRuns(entry.message ?? ''), 4000), raw: truncateWithNote(foldLongRuns(entry.raw ?? ''), 4000) })) : [], triggerCommand: event?.msg || '未知命令或处于非消息环境', frameworkVersion: Root.karinVersion, // 和主布局用同一份 logo(跨模块导入以免又出现「旧头像」) frameworkLogo: resolveFrameLogo(), pluginVersion: Root.pluginVersion, buildTime: buildMetadata?.buildTime ? formatBuildTime(buildMetadata.buildTime) : undefined, commitHash: buildMetadata?.commitHash, /** * 之前这里可能传 undefined,模板读 adapterInfo.version.startsWith 直接 SSR 崩掉。 * * 名称和图标依据都交给 adapterCardInfo:它会先问一句实现端(`get_version_info`) * 再取快照,于是 NapCat 能印成 `onebot(nc)` 并配上 NapCat 的图标 —— * 光看平台名各家都是 `onebot`,是分不出来的。 */ adapterInfo: await adapterCardInfo(event?.bot), // 上报成功的编号与反馈群:印在卡片上,用户照着进群提问 report: ctx.report ? { id: ctx.report.id, url: ctx.report.url, group: reportConfig().group, groupUrl: groupLinkOf(reportConfig().group) } : undefined, isVerification: opts.isVerification, verificationUrl: opts.verificationUrl, share_url: opts.share_url }) }