koishi-plugin-kkk 3.3.2 → 3.4.0

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/lib/index.js CHANGED
@@ -146,67 +146,66 @@ const NATIVE_KEYS = ['masters', 'dataPath', 'debug', 'autoParse', 'webUiAuth'];
146
146
  * 一样会把已有值带上,面板里的设置不会被清空。
147
147
  */
148
148
  const WEBUI_GUIDE = [
149
- '**所有设置都在 WebUI 配置面板里改**,地址:/kkk(打开 Koishi 控制台后,左侧边栏也有一个「**kkk 配置**」入口)。',
149
+ '两种改法都行,改哪边都生效,改完记得点保存。',
150
150
  '',
151
- '面板里包含:接口库 / 通用(含**错误上报**、在线播放器、合并转发)/ 抖音 / 哔哩哔哩 / 快手 / 小红书 / **QQ 适配器** / 推送列表。' +
152
- '改完点右下角保存即可 —— 会写回 koishi.yml 并热重载,**不用重启**。',
151
+ '一、就在这个页面改:下面的设置项都在,常用的有「是否发解析面板」「画质档体积上限」「超长图自动切片」「在线播放器」「错误上报」等。',
153
152
  '',
154
- '**Koishi 控制台里的设置项已经全部隐藏**(值仍然保留,在这里点保存也不会把它们弄丢),免得两边各改一半、互相覆盖。',
153
+ '二、用配置面板改:打开 Koishi 控制台后,左侧边栏有一个「kkk 配置」入口,也可以直接访问 /kkk。',
154
+ '面板界面更直观,接口库、抖音、哔哩哔哩、快手、小红书、推送列表都在里面,改完点右下角保存,不用重启 Koishi。',
155
155
  '',
156
- '面板里还有一组「**Koishi 设置**」(数据目录、调试日志、自动解析、面板是否要求先登录控制台)。' +
157
- '只有 masters(主人账号)没有面板入口 —— 它属于 Koishi 的权限体系,而面板是免登录页面,' +
158
- '需要直接改 koishi.yml 里本插件的配置段(改完重启 Koishi)。'
156
+ '两边的说明和默认值都是同一份,不会出现「面板里有、控制台里没有」。' +
157
+ '面板是免登录页面,所以「主人账号」只能在控制台或 koishi.yml 里改。'
159
158
  ].join('\n');
160
159
  exports.Config = koishi_1.Schema.intersect([
161
160
  koishi_1.Schema.object({
162
161
  webuiGuide: koishi_1.Schema.const('').description(WEBUI_GUIDE),
163
- // 全部隐藏:控制台表单里不显示,配置统一在控制台侧边栏的「kkk 配置」面板里改
164
- qq: (0, qqOptions_1.buildQqSchema)(koishi_1.Schema).hidden().description('QQ 适配器(只对 QQ 平台生效)'),
162
+ /**
163
+ * 常用设置(面板 / 切片 / 在线播放器 / 错误上报)。
164
+ *
165
+ * 这里**不做嵌套分组**:Koishi 的插件配置表单是按 schema 的形状渲染的,
166
+ * 想在里面再分小节就得把值也改成嵌套结构 —— 而面板(/kkk)写回 koishi.yml 用的是扁平结构,
167
+ * 两边一旦不一致,控制台一保存就会把这些设置丢回默认值。
168
+ * 所以保持扁平,靠「字段顺序 + 每条说人话的说明」来保证可读性。
169
+ */
170
+ qq: (0, qqOptions_1.buildQqSchema)(koishi_1.Schema).description('常用设置:面板、切片、在线播放器、错误上报'),
165
171
  advanced: koishi_1.Schema.object({
166
- masters: koishi_1.Schema.array(koishi_1.Schema.string()).default([]).description('主人账号(用户 ID,例如 123456789):接收报错通知,以及执行只有主人能用的指令'),
167
- dataPath: koishi_1.Schema.string().default('data').description('数据目录:配置、数据库、临时文件都放在这里'),
168
- debug: koishi_1.Schema.boolean().default(false).description('在日志里输出调试信息,排查问题时才需要打开'),
169
- autoParse: koishi_1.Schema.boolean().default(true).description('群里有人发链接(或回复一条带链接的消息)就自动解析,不用打指令'),
170
- webUiAuth: koishi_1.Schema.boolean().default(true).description('配置面板 /kkk 是否要求先登录 Koishi 控制台(默认开)。装了 auth 插件的部署只有登录后才能打开面板;没装 auth 插件时本来就没有登录这一说,这里不生效'),
171
- }).collapse().hidden().description('Koishi 原生设置(一般不用改,已折叠)'),
172
- }).description('配置入口:请在 WebUI 面板(/kkk)里修改'),
172
+ masters: koishi_1.Schema.array(koishi_1.Schema.string()).default([])
173
+ .description('主人账号,填用户 ID(不是 QQ 号),例如 123456789。可以收到报错通知,也能执行只有主人能用的指令'),
174
+ dataPath: koishi_1.Schema.string().default('data')
175
+ .description('数据目录:配置、数据库、临时文件都放在这里。改完要重启 Koishi'),
176
+ debug: koishi_1.Schema.boolean().default(false)
177
+ .description('在日志里输出调试信息。排查问题时才需要打开,平时会很吵'),
178
+ autoParse: koishi_1.Schema.boolean().default(true)
179
+ .description('群里有人发链接(或者回复一条带链接的消息)就自动解析,不用打指令'),
180
+ webUiAuth: koishi_1.Schema.boolean().default(true)
181
+ .description('配置面板 /kkk 是否要求先登录 Koishi 控制台。装了 auth 插件的部署建议保持打开;没装 auth 插件时本来就没有登录这一步,这里不生效'),
182
+ }).description('Koishi 原生设置(一般不用改)'),
183
+ }),
173
184
  koishi_1.Schema.object({
174
185
  /**
175
- * 合并转发(两级开关)。
176
- *
177
- * 面板(/kkk 的 SPA)是上游打包好的产物,加不了新字段,所以这里单独给一组能在
178
- * **控制台**里改的表单:全局开关 + 每个平台各自的开关与「合并哪些内容」。
179
- * 值写回 config.json 的对应位置(app.fakeForward / app.forwardContent / <平台>.forward …)。
186
+ * 合并转发(两级开关):值写回 config.json 的 app.fakeForward / app.forwardContent / <平台>.forward。
187
+ * 面板里也有同一组开关(见 scripts/patch-webui.mjs),改哪边都行,两边读的是同一份配置。
180
188
  */
181
189
  forward: koishi_1.Schema.object({
182
- guide: koishi_1.Schema.const('').description('**合并转发**:打开后,一次解析产生的所有内容会等解析全部结束、合并成一条转发发出;' +
183
- '关掉就是一边解析一边逐条发。\n\n' +
184
- '**全局优先**:上面的「全局」打开 → 所有平台都合并,下面的平台开关不再起作用;' +
185
- '全局关着时,才轮到各平台自己的开关。\n\n' +
186
- '**默认全部关闭**。\n\n' +
187
- '⚠️ 合并转发开着时,视频这类大文件如果塞进转发节点,某些适配器(如 NapCat)会整条拒绝 —— ' +
188
- '这时它会自动改成单独发送,不会丢内容。\n\n' +
189
- '另外:**markdown 只有 QQ 官方 bot 支持**,OneBot(NapCat / Lagrange 等个人号)不渲染,' +
190
- '所以那边图片一律按普通图片段发(切片、图集都是),配置里的 markdown 选项对它没有意义。'),
191
190
  global: koishi_1.Schema.boolean().default(false)
192
- .description('全局合并转发:打开后所有平台都合并(优先级高于下面的平台开关)'),
191
+ .description('全局合并转发。打开后所有平台都把一次解析的内容合成一条聊天记录发出;关着时下面各平台的开关才起作用。默认关闭'),
193
192
  globalContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
194
- .description('全局转发里合并哪些内容:text 文字 / image 图片 / video 视频 / file 文件。留空 = 用默认(text、image)。**语音和 markdown 不在候选里**:QQ 的聊天记录不支持语音气泡,markdown 只有官方 bot 认而官方适配器没有合并转发能力;没列出来的内容单独直发'),
195
- douyin: koishi_1.Schema.boolean().default(false).description('抖音:单独打开合并转发(全局关着时生效)'),
193
+ .description('全局合并转发里放哪些内容:text 文字 / image 图片 / video 视频 / file 文件。没勾的单独直发,留空等于只放文字和图片。视频体积大时有些适配器(比如 NapCat)会拒绝整个聊天记录,这时会自动改成单独发送,不会丢内容'),
194
+ douyin: koishi_1.Schema.boolean().default(false).description('抖音:单独打开合并转发(全局关着时才起作用)'),
196
195
  douyinContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
197
- .description('抖音转发合并哪些内容(留空 = 用全局那份)'),
198
- bilibili: koishi_1.Schema.boolean().default(false).description('B站:单独打开合并转发(全局关着时生效)'),
196
+ .description('抖音合并转发里放哪些内容,留空表示用全局那一份'),
197
+ bilibili: koishi_1.Schema.boolean().default(false).description('B站:单独打开合并转发(全局关着时才起作用)'),
199
198
  bilibiliContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
200
- .description('B站转发合并哪些内容(留空 = 用全局那份)'),
201
- kuaishou: koishi_1.Schema.boolean().default(false).description('快手:单独打开合并转发(全局关着时生效)'),
199
+ .description('B站合并转发里放哪些内容,留空表示用全局那一份'),
200
+ kuaishou: koishi_1.Schema.boolean().default(false).description('快手:单独打开合并转发(全局关着时才起作用)'),
202
201
  kuaishouContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
203
- .description('快手转发合并哪些内容(留空 = 用全局那份)'),
204
- xiaohongshu: koishi_1.Schema.boolean().default(false).description('小红书:单独打开合并转发(全局关着时生效)'),
202
+ .description('快手合并转发里放哪些内容,留空表示用全局那一份'),
203
+ xiaohongshu: koishi_1.Schema.boolean().default(false).description('小红书:单独打开合并转发(全局关着时才起作用)'),
205
204
  xiaohongshuContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
206
- .description('小红书转发合并哪些内容(留空 = 用全局那份)')
207
- }).collapse().hidden().description('合并转发(全局 + 各平台)'),
208
- upstream: (0, schema_1.buildUpstreamSchema)(pluginRootDir).hidden().description('插件自身的配置(与 Karin 版 config.json 一致)。每项都带着上游默认值,枚举型字段是下拉框;' +
209
- '**与默认值不同**的项会在启动时写回 config.json,保持默认值的项不写(这样你直接改文件的内容不会被覆盖)')
205
+ .description('小红书合并转发里放哪些内容,留空表示用全局那一份')
206
+ }).description('合并转发:把一次解析的内容合成一条聊天记录发出'),
207
+ upstream: (0, schema_1.buildUpstreamSchema)(pluginRootDir).description('插件配置:接口库(Cookie / 代理 / API 服务)、抖音 / B站 / 快手 / 小红书 的解析与推送、推送订阅列表。'
208
+ + '每项都带默认值,枚举型是下拉框;和默认值不同的项会写回 config.json,保持默认值的不写(这样你直接改文件的内容不会被覆盖)')
210
209
  })
211
210
  ]);
212
211
  exports.usage = `
@@ -706,6 +705,33 @@ function registerCommands(ctx, logger, autoParse) {
706
705
  logger.debug('卡片来源不是 B站(%s),跳过卡片解析', cardPlatform || '未知');
707
706
  return next();
708
707
  }
708
+ /**
709
+ * **同一条卡片消息被投递多遍时,只处理第一遍**。
710
+ *
711
+ * 线上实测:同一条 B站卡片会在几秒内进来两次(平台重投 / 另一个中间件再发一遍),
712
+ * 于是 OCR + 搜索各跑两遍、面板也可能发两条 —— 用户看到的就是「解析完了又解析一遍」。
713
+ *
714
+ * 去重键里的卡片正文要**先去掉 URL 的签名参数**:同一条卡片重投时图片链接会被重新签名,
715
+ * 直接拿原文哈希会认为是两条不同的消息(踩过这个坑,第二次照样 OCR)。
716
+ */
717
+ const cardSignature = raw
718
+ .replace(/https?:\/\/[^\s"'<>]+/g, (url) => url.split("?")[0])
719
+ .replace(/\s+/g, " ")
720
+ .trim()
721
+ .slice(0, 2000);
722
+ /**
723
+ * 打一条「收到卡片」的日志(debug 级,排查重复投递用)。
724
+ *
725
+ * 同一条卡片连着来两次、而 **messageId 也相同** → 这条事件被重复投递/重复处理;
726
+ * messageId 不同 → 平台那边确实又发了一条。排查「到底谁重复了」就看它。
727
+ */
728
+ logger.debug('收到卡片消息(messageId=%s,平台=%s)', String(session.messageId ?? '无'), String(session.platform ?? '未知'));
729
+ const { acquireParseLock } = await Promise.resolve().then(() => __importStar(require("./karin/module/utils/ParseLock")));
730
+ const cardKey = ["card", String(session.platform ?? ""), String(session.channelId ?? ""), String(session.userId ?? ""), cardSignature].join(":");
731
+ if (!acquireParseLock(cardKey)) {
732
+ logger.debug("短时间内重复的同一条卡片消息,已忽略(不再重复 OCR/搜索): %s", cardKey.slice(0, 120));
733
+ return;
734
+ }
709
735
  const send = async (content) => {
710
736
  try {
711
737
  await session.send(content);
@@ -4,6 +4,8 @@ exports.wrapWithErrorHandler = exports.handleBusinessError = void 0;
4
4
  const node_karin_1 = require("../../../../compat/node-karin.js");
5
5
  const module_1 = require("../../../module");
6
6
  const EmojiReaction_1 = require("../../../module/utils/EmojiReaction");
7
+ // 注意路径只有四层 ..:ErrorHandler → utils → module → karin → src(多一层就指到仓库根了)
8
+ const runtime_1 = require("../../../../compat/runtime");
7
9
  const ErrorReport_1 = require("../ErrorReport");
8
10
  const ImageSlice_1 = require("../ImageSlice");
9
11
  const render_1 = require("./render");
@@ -51,19 +53,37 @@ const handleBusinessError = async (error, options, logs, event) => {
51
53
  return 'handled';
52
54
  }
53
55
  }
54
- let img = await (0, render_1.renderErrorImage)(ctx);
55
56
  /**
56
- * 错误卡片也会超长(实测 2880×40000 / 45MB),直接发必被 QQ 拒收 ——
57
- * 统一切片:官方 QQ 切成一条 markdown,OneBot 切成若干图片段(它不渲染 markdown),
58
- * 失败就退回原图。
57
+ * **「出错只发文字」开关**(面板 通用 → 错误上报 → 出错只发文字,配置项 errorNoCard)。
58
+ *
59
+ * 打开后:**不渲染错误卡片**(省掉十几秒渲染 + 一张几 MB 的大图),
60
+ * 只把「错误报告已上传,ID:xxx / 可以前往 QQ 群内寻找帮助」那几行发出去 —— **上报照旧**。
61
+ * 传空数组进去就行:下面几个发送函数本来就是「卡片 + 提示文案」拼一起发的(见 sender 的 errorHelpSegments)。
59
62
  */
63
+ let textOnly = false;
60
64
  try {
61
- const sliced = await (0, ImageSlice_1.sliceImageToElements)(img, (0, ImageSlice_1.platformOf)(event));
62
- if (sliced?.length)
63
- img = sliced;
65
+ textOnly = (0, runtime_1.tryGetRuntime)()?.config?.errorNoCard === true;
64
66
  }
65
- catch (sliceError) {
66
- node_karin_1.logger.debug('[ErrorHandler] 错误卡片切片失败,按原图发送: ' + String(sliceError?.message ?? sliceError));
67
+ catch { /* 读不到配置就按老的来(渲染卡片) */ }
68
+ let img = [];
69
+ if (textOnly) {
70
+ node_karin_1.logger.mark('[ErrorHandler] 配置为「出错只发文字」,跳过错误卡片渲染(错误上报与提示文案照旧)');
71
+ }
72
+ else {
73
+ img = await (0, render_1.renderErrorImage)(ctx);
74
+ /**
75
+ * 错误卡片也会超长(实测 2880×40000 / 45MB),直接发必被 QQ 拒收 ——
76
+ * 统一切片:官方 QQ 切成一条 markdown,OneBot 切成若干图片段(它不渲染 markdown),
77
+ * 失败就退回原图。
78
+ */
79
+ try {
80
+ const sliced = await (0, ImageSlice_1.sliceImageToElements)(img, (0, ImageSlice_1.platformOf)(event));
81
+ if (sliced?.length)
82
+ img = sliced;
83
+ }
84
+ catch (sliceError) {
85
+ node_karin_1.logger.debug('[ErrorHandler] 错误卡片切片失败,按原图发送: ' + String(sliceError?.message ?? sliceError));
86
+ }
67
87
  }
68
88
  await (0, sender_1.sendErrorToTrigger)(ctx, img);
69
89
  await (0, sender_1.sendErrorToMaster)(ctx, img);
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.errorHelpSegments = exports.uploadErrorReport = exports.groupLinkOf = exports.REPORT_LOG_LINES = exports.REPORT_GROUP = exports.REPORT_TOKEN = exports.REPORT_URL = void 0;
6
+ exports.errorHelpSegments = exports.uploadErrorReport = exports.groupLinkOf = exports.GROUP_INVITE_URL = exports.REPORT_LOG_LINES = exports.REPORT_GROUP = exports.REPORT_TOKEN = exports.REPORT_URL = void 0;
7
7
  exports.reportConfig = reportConfig;
8
8
  /**
9
9
  * 错误上报。
@@ -70,8 +70,15 @@ function reportConfig() {
70
70
  }
71
71
  return { enabled, url: exports.REPORT_URL, token: exports.REPORT_TOKEN, logLines: exports.REPORT_LOG_LINES, group: exports.REPORT_GROUP };
72
72
  }
73
- /** 反馈群链接:QQ 官方的加群链接格式,面板里只填群号 */
74
- const groupLinkOf = (group) => 'https://qm.qq.com/q/' + group;
73
+ /**
74
+ * 反馈群的加群链接。
75
+ *
76
+ * ⚠️ **不能拿群号拼**:qm.qq.com/q/ 后面跟的是 QQ 给的**分享码**(一长串字母),
77
+ * 不是群号 —— 写成 https://qm.qq.com/q/1050229473 点开就是 404(用户实测反馈过)。
78
+ * 这里写死正确的分享链接;群号仍然照常显示在文案里。
79
+ */
80
+ exports.GROUP_INVITE_URL = 'https://qm.qq.com/q/viymkIPvvq';
81
+ const groupLinkOf = (_group) => exports.GROUP_INVITE_URL;
75
82
  exports.groupLinkOf = groupLinkOf;
76
83
  /**
77
84
  * 已安装插件清单(名字 + 版本)。
@@ -255,7 +255,24 @@ const sendSlicedImage = async (e, input) => {
255
255
  * 不加超时整条流程就停在这里,用户看到的就是「没反应」。
256
256
  */
257
257
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
258
+ /**
259
+ * 适配器多久没返回算「卡住」。
260
+ *
261
+ * 线上踩坑(用户实测):一条 3MB 的评论卡走 QQ 官方上传经常要 15~40 秒,
262
+ * 而老代码 **15 秒就判「发送未成功」** → 去切片再发一遍 → 群里出现两张(第一张其实发出去了),
263
+ * 日志就是那句「普通发送未成功(发送超过 15s 没有返回),大图不重试,改走切片」。
264
+ *
265
+ * 现在拆成三段:
266
+ * 15 秒 —— 只在日志里提示「还在传,继续等」(不判失败);
267
+ * 120 秒 —— 到这里才怀疑它卡住;
268
+ * 再等 30 秒宽限 —— 等那个迟到的结果,成功了就当成功(不再重复发)。
269
+ */
270
+ const SEND_NOTICE_MS = 15000;
271
+ const SEND_HARD_TIMEOUT_MS = 120000;
272
+ const SEND_GRACE_MS = 30000;
273
+ const TIMED_OUT = Symbol('send-timeout');
258
274
  const tryNormalSendOnce = async () => {
275
+ const startedAt = Date.now();
259
276
  const sendPromise = (async () => {
260
277
  try {
261
278
  const result = await e.reply(node_karin_1.segment.image(source));
@@ -269,13 +286,42 @@ const sendSlicedImage = async (e, input) => {
269
286
  return (0, sendError_1.classifySendFailure)(error);
270
287
  }
271
288
  })();
272
- const timeoutPromise = new Promise((resolve) => {
273
- const timer = setTimeout(() => resolve({
274
- kind: 'transient', message: '发送超过 15s 没有返回', retryable: true
275
- }), 15000);
276
- timer.unref?.();
277
- });
278
- return await Promise.race([sendPromise, timeoutPromise]);
289
+ const notice = setTimeout(() => {
290
+ node_karin_1.logger.mark('[图片切片] 适配器还没返回(已 ' + Math.round(SEND_NOTICE_MS / 1000) + 's),继续等 —— 大图上传本来就要几十秒,超时不代表没发出去');
291
+ }, SEND_NOTICE_MS);
292
+ notice.unref?.();
293
+ const raced = await Promise.race([
294
+ sendPromise,
295
+ new Promise((resolve) => {
296
+ const timer = setTimeout(() => resolve(TIMED_OUT), SEND_HARD_TIMEOUT_MS);
297
+ timer.unref?.();
298
+ })
299
+ ]);
300
+ clearTimeout(notice);
301
+ if (raced !== TIMED_OUT)
302
+ return raced;
303
+ /**
304
+ * 硬超时后再宽限一会儿:适配器可能只是慢,这一等能把「其实发出去了」的迟到结果等回来,
305
+ * 避免外层去切片、把同一张图又发一遍。
306
+ */
307
+ node_karin_1.logger.mark('[图片切片] 适配器 ' + Math.round(SEND_HARD_TIMEOUT_MS / 1000) + 's 没有返回,再等 ' + Math.round(SEND_GRACE_MS / 1000) + 's 看它是卡住还是在上传');
308
+ const graced = await Promise.race([
309
+ sendPromise,
310
+ new Promise((resolve) => {
311
+ const timer = setTimeout(() => resolve(TIMED_OUT), SEND_GRACE_MS);
312
+ timer.unref?.();
313
+ })
314
+ ]);
315
+ if (graced !== TIMED_OUT) {
316
+ node_karin_1.logger.mark('[图片切片] 那次「超时」的发送其实成功了(适配器 ' + Math.round((Date.now() - startedAt) / 1000) + 's 才返回),不再切片重发');
317
+ return graced;
318
+ }
319
+ // 到这里才真的当作「发不出去」;注意它仍然是**不确定**,不是确定失败,所以不重试
320
+ return {
321
+ kind: 'unconfirmed',
322
+ message: '发送超过 ' + Math.round((SEND_HARD_TIMEOUT_MS + SEND_GRACE_MS) / 1000) + 's 没有返回(适配器可能还卡在上传)',
323
+ retryable: false
324
+ };
279
325
  };
280
326
  /**
281
327
  * 带**重试**的普通发送(用户要求)。
@@ -397,6 +397,59 @@ async function recallLastPanel(e) {
397
397
  node_karin_1.logger.debug('[QQ面板] 撤回上一条面板失败: ' + String(error));
398
398
  }
399
399
  }
400
+ /**
401
+ * ── 「信息卡片」和「markdown 选择表」分开记、分开撤 ───────────────────────
402
+ *
403
+ * 用户要求:**卡片不要和「选清晰度 / 选第几集」的 markdown 一起发**,
404
+ * 分开发并且**只撤回 markdown**(卡片留着给用户看)。
405
+ *
406
+ * 所以这里多一份「上一条卡片」的记录:
407
+ * - 选清晰度 / 翻页 / 选集时只撤 markdown(原来的 lastPanelMessages);
408
+ * - **只有再发一张新卡片时**才把上一张卡片撤掉(否则番剧翻页几次群里就堆一排卡片)。
409
+ */
410
+ const lastPanelCards = new Map();
411
+ /** 记住刚刚发出的信息卡片(只在发新卡片时撤回) */
412
+ function rememberPanelCard(e, messageId) {
413
+ if (!messageId)
414
+ return;
415
+ try {
416
+ const key = String(e.contact?.peer ?? e.channelId ?? '');
417
+ if (!key)
418
+ return;
419
+ lastPanelCards.set(key, { id: String(messageId), at: Date.now() });
420
+ if (lastPanelCards.size > 128)
421
+ lastPanelCards.delete(lastPanelCards.keys().next().value);
422
+ }
423
+ catch { /* 记不住也不影响功能 */ }
424
+ }
425
+ /** 撤回这个频道上一条「信息卡片」(发新卡片前调用;配置关掉撤回时什么都不做) */
426
+ async function recallLastPanelCard(e) {
427
+ const runtime = (0, runtime_1.tryGetRuntime)();
428
+ if (!runtime || runtime.config?.recallPanel === false)
429
+ return;
430
+ try {
431
+ const key = String(e.contact?.peer ?? e.channelId ?? '');
432
+ const entry = key ? lastPanelCards.get(key) : undefined;
433
+ if (!entry)
434
+ return;
435
+ lastPanelCards.delete(key);
436
+ if (Date.now() - entry.at > LAST_PANEL_TTL)
437
+ return;
438
+ await e.bot?.recallMsg?.(entry.id, key);
439
+ }
440
+ catch (error) {
441
+ node_karin_1.logger.debug('[QQ面板] 撤回上一条卡片失败: ' + String(error));
442
+ }
443
+ }
444
+ /** 只撤掉某条消息(例如「加载中…」),不动上面两份记录 */
445
+ async function recallMessageById(e, id) {
446
+ if (!id)
447
+ return;
448
+ try {
449
+ await e.bot?.recallMsg?.(id, String(e.contact?.peer ?? e.channelId ?? ''));
450
+ }
451
+ catch { /* 撤不掉就留着 */ }
452
+ }
400
453
  /**
401
454
  * 面板渲染比较慢(大卡片要十几秒),先回一句「加载中…」再渲染。
402
455
  * 返回这条提示的消息 id,渲染完由调用方撤回。
@@ -656,7 +709,21 @@ async function sendBangumiPanelPage(e, episodes, cardData, page = 1) {
656
709
  if (!card)
657
710
  return false;
658
711
  const parseCommand = (0, runtime_1.commandInvocation)('解析');
659
- const lines = ['![#' + card.width + 'px #' + card.height + 'px](' + card.url + ')'];
712
+ /**
713
+ * **卡片单独发一条**(用户要求:不要和「选第几集」的表格挤在一起),
714
+ * 并且不记进「上一条面板」—— 翻页 / 选集时只撤表格。
715
+ * 翻页会重新渲染卡片,所以这里先撤掉上一张卡片,免得翻几次堆一排(见 recallLastPanelCard)。
716
+ */
717
+ const lines = [];
718
+ try {
719
+ const cardLines = ["![#" + card.width + "px #" + card.height + "px](" + card.url + ")"];
720
+ await recallLastPanelCard(e);
721
+ const sentCard = await sendPanelMarkdown(cardLines, (content) => e.reply(content));
722
+ rememberPanelCard(e, sentCard?.sent?.messageId);
723
+ }
724
+ catch (error) {
725
+ node_karin_1.logger.debug("[QQ面板] 番剧卡片单独发送失败(不影响选集表格): " + String(error?.message ?? error));
726
+ }
660
727
  /**
661
728
  * 一格:纯数字按钮,点下去就是选这一集。
662
729
  *
@@ -772,9 +839,26 @@ async function sendQqParsePanel(e, request) {
772
839
  // 这一段要拉弹幕 + 渲染卡片(十几秒),先给一句「加载中…」,也顺便撤掉上一条面板
773
840
  const loadingId = await showLoadingTip(e);
774
841
  const card = await uploadPanelCard(e, request, info.detail, info.hotDanmaku ?? []);
842
+ /**
843
+ * **卡片单独发一条**(用户要求:不要和「选清晰度」的 markdown 挤在一条消息里)。
844
+ *
845
+ * 而且它**不记进「上一条面板」** —— 下一步选清晰度/翻页时只撤 markdown 那张表,
846
+ * 卡片留在群里;只有再解析一次(发新卡片)时才会把旧的撤掉(见 rememberPanelCard)。
847
+ */
848
+ let cardSent = false;
775
849
  if (card && card.url) {
776
850
  // QQ markdown 的图片必须写成 ![#宽px #高px](url)
777
- lines.push('![#' + (card.width || 1440) + 'px #' + (card.height || 1080) + 'px](' + card.url + ')');
851
+ const cardLines = ["![#" + (card.width || 1440) + "px #" + (card.height || 1080) + "px](" + card.url + ")"];
852
+ try {
853
+ await recallLastPanelCard(e);
854
+ await recallMessageById(e, loadingId);
855
+ const sentCard = await sendPanelMarkdown(cardLines, (content) => e.reply(content));
856
+ rememberPanelCard(e, sentCard?.sent?.messageId);
857
+ cardSent = true;
858
+ }
859
+ catch (error) {
860
+ node_karin_1.logger.debug("[QQ面板] 信息卡片单独发送失败(不影响选择表): " + String(error?.message ?? error));
861
+ }
778
862
  }
779
863
  // 按钮里只放短令牌,链接存在内存里(见 rememberPanelRequest 的说明);
780
864
  // 顺带把各档体积存进去:解析时能在「收到请求,开始下载」里提示会不会变成文件发送
@@ -933,7 +1017,13 @@ async function sendQqParsePanel(e, request) {
933
1017
  if (runtime.config.qqPanelSourceLink !== false && request.url)
934
1018
  lines.push(sourceLink('打开原站', request.url));
935
1019
  // 带链接发不出去时自动去掉链接行重发(不然整条面板、整个解析都会被一个链接拖死)
936
- await sendPanelMarkdown(lines, (content) => replaceLoadingTip(e, loadingId, content));
1020
+ const { sent } = await sendPanelMarkdown(lines, async (content) => {
1021
+ // 卡片已经撤过「加载中…」了;卡片没发出去(或渲染失败)时这里补撤一次
1022
+ if (!cardSent)
1023
+ await recallMessageById(e, loadingId);
1024
+ return await e.reply(content);
1025
+ });
1026
+ rememberPanelMessage(e, sent?.messageId);
937
1027
  node_karin_1.logger.debug('[QQ面板] 已发送解析面板: ' + request.platform + ' ' + request.id + '(' + shown.length + '/' + info.options.length + ' 档画质)');
938
1028
  return true;
939
1029
  }
@@ -239,21 +239,66 @@ class Xiaohongshu extends module_1.Base {
239
239
  node_karin_2.logger.warn('[小红书] 发送图文图片失败: ' + String(error?.message ?? error).slice(0, 120));
240
240
  }
241
241
  }
242
+ /**
243
+ * 带「先提示、再宽限」的超时包装(和图片发送那边同一套口径,见 ImageSlice 的 SEND_* 常量)。
244
+ *
245
+ * 老写法是「到点就 Promise.race 出 null」—— 很多请求只是**慢**,不是挂了(小红书接口尤其如此),
246
+ * 早放弃的代价就是「评论区凭空少了内容」。现在分三段:
247
+ * noticeMs 只在日志里提示一句「还在拉,继续等」;
248
+ * hardMs 才怀疑它挂住;
249
+ * graceMs 再等一会儿那个迟到的结果,回来了就当成功。
250
+ *
251
+ * @returns 任务结果;真超时返回 null(调用方按「拿不到数据」处理即可)
252
+ */
253
+ const withNoticeTimeout = async (label, task, options) => {
254
+ const timedOut = Symbol("timeout");
255
+ const startedAt = Date.now();
256
+ const seconds = (ms) => Math.round(ms / 1000);
257
+ const notice = setTimeout(() => {
258
+ node_karin_2.logger.mark("[小红书] " + label + "还在进行(已 " + seconds(options.noticeMs) + "s),继续等 —— 接口只是慢,不代表失败");
259
+ }, options.noticeMs);
260
+ notice.unref?.();
261
+ /** 包一层:任务本身抛错时也要能让 Promise.race 正常结束,错由调用方的 try/catch 接住 */
262
+ const guarded = task.catch((error) => { throw error; });
263
+ const raced = await Promise.race([
264
+ guarded,
265
+ new Promise((resolve) => {
266
+ const timer = setTimeout(() => resolve(timedOut), options.hardMs);
267
+ timer.unref?.();
268
+ })
269
+ ]);
270
+ clearTimeout(notice);
271
+ if (raced !== timedOut)
272
+ return raced;
273
+ node_karin_2.logger.mark("[小红书] " + label + "已 " + seconds(options.hardMs) + "s 没有返回,再等 " + seconds(options.graceMs) + "s 看它是挂住还是只是慢");
274
+ const graced = await Promise.race([
275
+ guarded,
276
+ new Promise((resolve) => {
277
+ const timer = setTimeout(() => resolve(timedOut), options.graceMs);
278
+ timer.unref?.();
279
+ })
280
+ ]);
281
+ if (graced !== timedOut) {
282
+ node_karin_2.logger.mark("[小红书] " + label + "其实成功了(" + seconds(Date.now() - startedAt) + "s 才返回),这次不算超时");
283
+ return graced;
284
+ }
285
+ node_karin_2.logger.warn("[小红书] " + label + "超时(共 " + seconds(Date.now() - startedAt) + "s),本次跳过");
286
+ return null;
287
+ };
242
288
  /**
243
289
  * 卡片发完后再去拉表情表(给评论区做表情转换用)。
244
290
  * 这里挂掉/超时都不影响已经发出去的卡片。
245
291
  */
246
292
  try {
247
- const EmojiList = await Promise.race([
248
- this.amagi.xiaohongshu.fetcher.fetchEmojiList(),
249
- new Promise((resolve) => setTimeout(() => resolve(null), 8000))
250
- ]);
293
+ const EmojiList = await withNoticeTimeout('表情表拉取', this.amagi.xiaohongshu.fetcher.fetchEmojiList(), {
294
+ noticeMs: 8000, hardMs: 30000, graceMs: 10000
295
+ });
251
296
  if (EmojiList) {
252
297
  formattedEmojis = (0, exports.XiaohongshuEmoji)(EmojiList.data);
253
298
  node_karin_2.logger.mark('[小红书] 表情表已获取,共 ' + (Array.isArray(formattedEmojis) ? formattedEmojis.length : 0) + ' 条');
254
299
  }
255
300
  else {
256
- node_karin_2.logger.warn('[小红书] 表情表超时(8s),评论区将不做表情转换');
301
+ node_karin_2.logger.warn('[小红书] 表情表没拿到(超时),评论区将不做表情转换');
257
302
  }
258
303
  }
259
304
  catch (error) {
@@ -270,13 +315,13 @@ class Xiaohongshu extends module_1.Base {
270
315
  /** 评论是否拉取失败 —— 失败也要把评论卡渲染出来(只是内容为空 + 一句提示) */
271
316
  let commentFailed = false;
272
317
  try {
273
- // 评论同样加超时:上游这条链路本来就常挂,别把整条解析拖死
274
- CommentData = await Promise.race([
275
- this.fetchConfiguredNoteComments(data),
276
- new Promise((resolve) => setTimeout(() => resolve(null), 15000))
277
- ]);
318
+ // 评论同样加超时:上游这条链路本来就常挂,别把整条解析拖死。
319
+ // 但**「慢」不是「挂」**:15 秒只提示、60 秒才怀疑、再给 15 秒宽限(见 withNoticeTimeout)。
320
+ CommentData = await withNoticeTimeout('评论拉取', this.fetchConfiguredNoteComments(data), {
321
+ noticeMs: 15000, hardMs: 60000, graceMs: 15000
322
+ });
278
323
  if (!CommentData)
279
- throw new Error('拉取评论超时(15s)');
324
+ throw new Error('拉取评论超时(提示 15s / 硬超时 60s + 宽限 15s 都没等到)');
280
325
  }
281
326
  catch (error) {
282
327
  /**
@@ -387,7 +387,7 @@ const handlerError = (props) => {
387
387
  }, children: "Support" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-2 gap-x-6 gap-y-6 text-2xl leading-relaxed py-6", style: {
388
388
  borderTop: `1px solid ${dark ? 'rgba(248,113,113,0.2)' : 'rgba(220,38,38,0.12)'}`,
389
389
  borderBottom: `1px solid ${dark ? 'rgba(248,113,113,0.2)' : 'rgba(220,38,38,0.12)'}`
390
- }, children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "GitHub Issue" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl break-all", style: { color: secondaryColor }, children: "https://github.com/maimai993/koishi-plugin-kkk/issues/new/choose" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "GitHub Repository" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl break-all", style: { color: secondaryColor }, children: "https://github.com/maimai993/koishi-plugin-kkk" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "QQ \u7FA4" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl", style: { color: secondaryColor }, children: data.report?.group ?? '1050229473' }), (0, jsx_runtime_1.jsx)("p", { className: "text-lg break-all opacity-80", style: { color: secondaryColor }, children: data.report?.groupUrl ?? 'https://qm.qq.com/q/1050229473' })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "\u9644\u5E26\u4FE1\u606F" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl", style: { color: secondaryColor }, children: "\u6B64\u56FE\u7247 + \u89E6\u53D1\u547D\u4EE4 + \u5BF9\u5E94\u914D\u7F6E\uFF08\u81EA\u884C\u8131\u654F\u5904\u7406\uFF09" })] }), data.report && ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "\u9519\u8BEF\u4E0A\u62A5\u7F16\u53F7" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl break-all font-mono", style: { color: secondaryColor }, children: data.report.id }), (0, jsx_runtime_1.jsx)("p", { className: "text-lg break-all opacity-80", style: { color: secondaryColor }, children: data.report.url })] }))] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-4 mt-6 text-xl", style: { color: mutedColor }, children: [(0, jsx_runtime_1.jsx)("span", { className: "font-mono", children: "Tips:" }), (0, jsx_runtime_1.jsx)("span", { children: "\u4FE1\u606F\u8D8A\u5B8C\u6574\uFF0C\u5B9A\u4F4D\u8D8A\u5FEB\u3002" })] })] })] })] })] }));
390
+ }, children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "GitHub Issue" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl break-all", style: { color: secondaryColor }, children: "https://github.com/maimai993/koishi-plugin-kkk/issues/new/choose" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "GitHub Repository" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl break-all", style: { color: secondaryColor }, children: "https://github.com/maimai993/koishi-plugin-kkk" })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "QQ \u7FA4" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl", style: { color: secondaryColor }, children: data.report?.group ?? '1050229473' }), (0, jsx_runtime_1.jsx)("p", { className: "text-lg break-all opacity-80", style: { color: secondaryColor }, children: data.report?.groupUrl ?? 'https://qm.qq.com/q/viymkIPvvq' })] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "\u9644\u5E26\u4FE1\u606F" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl", style: { color: secondaryColor }, children: "\u6B64\u56FE\u7247 + \u89E6\u53D1\u547D\u4EE4 + \u5BF9\u5E94\u914D\u7F6E\uFF08\u81EA\u884C\u8131\u654F\u5904\u7406\uFF09" })] }), data.report && ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-semibold mb-1", style: { color: accentColor }, children: "\u9519\u8BEF\u4E0A\u62A5\u7F16\u53F7" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xl break-all font-mono", style: { color: secondaryColor }, children: data.report.id }), (0, jsx_runtime_1.jsx)("p", { className: "text-lg break-all opacity-80", style: { color: secondaryColor }, children: data.report.url })] }))] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-4 mt-6 text-xl", style: { color: mutedColor }, children: [(0, jsx_runtime_1.jsx)("span", { className: "font-mono", children: "Tips:" }), (0, jsx_runtime_1.jsx)("span", { children: "\u4FE1\u606F\u8D8A\u5B8C\u6574\uFF0C\u5B9A\u4F4D\u8D8A\u5FEB\u3002" })] })] })] })] })] }));
391
391
  };
392
392
  exports.handlerError = handlerError;
393
393
  exports.handlerError.displayName = 'handlerError';