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.
@@ -83,8 +83,16 @@ export function reportConfig (): ReportConfig {
83
83
  return { enabled, url: REPORT_URL, token: REPORT_TOKEN, logLines: REPORT_LOG_LINES, group: REPORT_GROUP }
84
84
  }
85
85
 
86
- /** 反馈群链接:QQ 官方的加群链接格式,面板里只填群号 */
87
- export const groupLinkOf = (group: string): string => 'https://qm.qq.com/q/' + group
86
+ /**
87
+ * 反馈群的加群链接。
88
+ *
89
+ * ⚠️ **不能拿群号拼**:qm.qq.com/q/ 后面跟的是 QQ 给的**分享码**(一长串字母),
90
+ * 不是群号 —— 写成 https://qm.qq.com/q/1050229473 点开就是 404(用户实测反馈过)。
91
+ * 这里写死正确的分享链接;群号仍然照常显示在文案里。
92
+ */
93
+ export const GROUP_INVITE_URL = 'https://qm.qq.com/q/viymkIPvvq'
94
+
95
+ export const groupLinkOf = (_group?: string): string => GROUP_INVITE_URL
88
96
 
89
97
  /**
90
98
  * 已安装插件清单(名字 + 版本)。
@@ -230,7 +230,25 @@ export const sendSlicedImage = async (e: Message, input: any): Promise<boolean>
230
230
  */
231
231
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
232
232
 
233
+ /**
234
+ * 适配器多久没返回算「卡住」。
235
+ *
236
+ * 线上踩坑(用户实测):一条 3MB 的评论卡走 QQ 官方上传经常要 15~40 秒,
237
+ * 而老代码 **15 秒就判「发送未成功」** → 去切片再发一遍 → 群里出现两张(第一张其实发出去了),
238
+ * 日志就是那句「普通发送未成功(发送超过 15s 没有返回),大图不重试,改走切片」。
239
+ *
240
+ * 现在拆成三段:
241
+ * 15 秒 —— 只在日志里提示「还在传,继续等」(不判失败);
242
+ * 120 秒 —— 到这里才怀疑它卡住;
243
+ * 再等 30 秒宽限 —— 等那个迟到的结果,成功了就当成功(不再重复发)。
244
+ */
245
+ const SEND_NOTICE_MS = 15000
246
+ const SEND_HARD_TIMEOUT_MS = 120000
247
+ const SEND_GRACE_MS = 30000
248
+ const TIMED_OUT = Symbol('send-timeout')
249
+
233
250
  const tryNormalSendOnce = async (): Promise<SendFailure | null> => {
251
+ const startedAt = Date.now()
234
252
  const sendPromise = (async (): Promise<SendFailure | null> => {
235
253
  try {
236
254
  const result: any = await e.reply(segment.image(source))
@@ -242,13 +260,41 @@ export const sendSlicedImage = async (e: Message, input: any): Promise<boolean>
242
260
  return classifySendFailure(error)
243
261
  }
244
262
  })()
245
- const timeoutPromise = new Promise<SendFailure>((resolve) => {
246
- const timer = setTimeout(() => resolve({
247
- kind: 'transient', message: '发送超过 15s 没有返回', retryable: true
248
- }), 15000)
249
- timer.unref?.()
250
- })
251
- return await Promise.race([sendPromise, timeoutPromise])
263
+ const notice = setTimeout(() => {
264
+ logger.mark('[图片切片] 适配器还没返回(已 ' + Math.round(SEND_NOTICE_MS / 1000) + 's),继续等 —— 大图上传本来就要几十秒,超时不代表没发出去')
265
+ }, SEND_NOTICE_MS)
266
+ notice.unref?.()
267
+ const raced = await Promise.race([
268
+ sendPromise,
269
+ new Promise<typeof TIMED_OUT>((resolve) => {
270
+ const timer = setTimeout(() => resolve(TIMED_OUT), SEND_HARD_TIMEOUT_MS)
271
+ timer.unref?.()
272
+ })
273
+ ])
274
+ clearTimeout(notice)
275
+ if (raced !== TIMED_OUT) return raced
276
+ /**
277
+ * 硬超时后再宽限一会儿:适配器可能只是慢,这一等能把「其实发出去了」的迟到结果等回来,
278
+ * 避免外层去切片、把同一张图又发一遍。
279
+ */
280
+ logger.mark('[图片切片] 适配器 ' + Math.round(SEND_HARD_TIMEOUT_MS / 1000) + 's 没有返回,再等 ' + Math.round(SEND_GRACE_MS / 1000) + 's 看它是卡住还是在上传')
281
+ const graced = await Promise.race([
282
+ sendPromise,
283
+ new Promise<typeof TIMED_OUT>((resolve) => {
284
+ const timer = setTimeout(() => resolve(TIMED_OUT), SEND_GRACE_MS)
285
+ timer.unref?.()
286
+ })
287
+ ])
288
+ if (graced !== TIMED_OUT) {
289
+ logger.mark('[图片切片] 那次「超时」的发送其实成功了(适配器 ' + Math.round((Date.now() - startedAt) / 1000) + 's 才返回),不再切片重发')
290
+ return graced
291
+ }
292
+ // 到这里才真的当作「发不出去」;注意它仍然是**不确定**,不是确定失败,所以不重试
293
+ return {
294
+ kind: 'unconfirmed',
295
+ message: '发送超过 ' + Math.round((SEND_HARD_TIMEOUT_MS + SEND_GRACE_MS) / 1000) + 's 没有返回(适配器可能还卡在上传)',
296
+ retryable: false
297
+ }
252
298
  }
253
299
 
254
300
  /**
@@ -395,6 +395,53 @@ export async function recallLastPanel (e: Message): Promise<void> {
395
395
  }
396
396
  }
397
397
 
398
+ /**
399
+ * ── 「信息卡片」和「markdown 选择表」分开记、分开撤 ───────────────────────
400
+ *
401
+ * 用户要求:**卡片不要和「选清晰度 / 选第几集」的 markdown 一起发**,
402
+ * 分开发并且**只撤回 markdown**(卡片留着给用户看)。
403
+ *
404
+ * 所以这里多一份「上一条卡片」的记录:
405
+ * - 选清晰度 / 翻页 / 选集时只撤 markdown(原来的 lastPanelMessages);
406
+ * - **只有再发一张新卡片时**才把上一张卡片撤掉(否则番剧翻页几次群里就堆一排卡片)。
407
+ */
408
+ const lastPanelCards = new Map<string, { id: string; at: number }>()
409
+
410
+ /** 记住刚刚发出的信息卡片(只在发新卡片时撤回) */
411
+ function rememberPanelCard (e: Message, messageId?: string) {
412
+ if (!messageId) return
413
+ try {
414
+ const key = String(e.contact?.peer ?? e.channelId ?? '')
415
+ if (!key) return
416
+ lastPanelCards.set(key, { id: String(messageId), at: Date.now() })
417
+ if (lastPanelCards.size > 128) lastPanelCards.delete(lastPanelCards.keys().next().value as string)
418
+ } catch { /* 记不住也不影响功能 */ }
419
+ }
420
+
421
+ /** 撤回这个频道上一条「信息卡片」(发新卡片前调用;配置关掉撤回时什么都不做) */
422
+ async function recallLastPanelCard (e: Message): Promise<void> {
423
+ const runtime: any = tryGetRuntime()
424
+ if (!runtime || runtime.config?.recallPanel === false) return
425
+ try {
426
+ const key = String(e.contact?.peer ?? e.channelId ?? '')
427
+ const entry = key ? lastPanelCards.get(key) : undefined
428
+ if (!entry) return
429
+ lastPanelCards.delete(key)
430
+ if (Date.now() - entry.at > LAST_PANEL_TTL) return
431
+ await (e.bot as any)?.recallMsg?.(entry.id, key)
432
+ } catch (error) {
433
+ logger.debug('[QQ面板] 撤回上一条卡片失败: ' + String(error))
434
+ }
435
+ }
436
+
437
+ /** 只撤掉某条消息(例如「加载中…」),不动上面两份记录 */
438
+ async function recallMessageById (e: Message, id?: string): Promise<void> {
439
+ if (!id) return
440
+ try {
441
+ await (e.bot as any)?.recallMsg?.(id, String(e.contact?.peer ?? e.channelId ?? ''))
442
+ } catch { /* 撤不掉就留着 */ }
443
+ }
444
+
398
445
  /**
399
446
  * 面板渲染比较慢(大卡片要十几秒),先回一句「加载中…」再渲染。
400
447
  * 返回这条提示的消息 id,渲染完由调用方撤回。
@@ -663,7 +710,20 @@ export async function sendBangumiPanelPage (e: Message, episodes: any[], cardDat
663
710
  if (!card) return false
664
711
 
665
712
  const parseCommand = commandInvocation('解析')
666
- const lines: string[] = ['![#' + card.width + 'px #' + card.height + 'px](' + card.url + ')']
713
+ /**
714
+ * **卡片单独发一条**(用户要求:不要和「选第几集」的表格挤在一起),
715
+ * 并且不记进「上一条面板」—— 翻页 / 选集时只撤表格。
716
+ * 翻页会重新渲染卡片,所以这里先撤掉上一张卡片,免得翻几次堆一排(见 recallLastPanelCard)。
717
+ */
718
+ const lines: string[] = []
719
+ try {
720
+ const cardLines = ["![#" + card.width + "px #" + card.height + "px](" + card.url + ")"]
721
+ await recallLastPanelCard(e)
722
+ const sentCard: any = await sendPanelMarkdown(cardLines, (content) => e.reply(content))
723
+ rememberPanelCard(e, sentCard?.sent?.messageId)
724
+ } catch (error: any) {
725
+ logger.debug("[QQ面板] 番剧卡片单独发送失败(不影响选集表格): " + String(error?.message ?? error))
726
+ }
667
727
 
668
728
  /**
669
729
  * 一格:纯数字按钮,点下去就是选这一集。
@@ -777,9 +837,25 @@ export async function sendQqParsePanel (e: Message, request: PanelRequest): Prom
777
837
  // 这一段要拉弹幕 + 渲染卡片(十几秒),先给一句「加载中…」,也顺便撤掉上一条面板
778
838
  const loadingId = await showLoadingTip(e)
779
839
  const card = await uploadPanelCard(e, request, info.detail, info.hotDanmaku ?? [])
840
+ /**
841
+ * **卡片单独发一条**(用户要求:不要和「选清晰度」的 markdown 挤在一条消息里)。
842
+ *
843
+ * 而且它**不记进「上一条面板」** —— 下一步选清晰度/翻页时只撤 markdown 那张表,
844
+ * 卡片留在群里;只有再解析一次(发新卡片)时才会把旧的撤掉(见 rememberPanelCard)。
845
+ */
846
+ let cardSent = false
780
847
  if (card && card.url) {
781
848
  // QQ markdown 的图片必须写成 ![#宽px #高px](url)
782
- lines.push('![#' + (card.width || 1440) + 'px #' + (card.height || 1080) + 'px](' + card.url + ')')
849
+ const cardLines = ["![#" + (card.width || 1440) + "px #" + (card.height || 1080) + "px](" + card.url + ")"]
850
+ try {
851
+ await recallLastPanelCard(e)
852
+ await recallMessageById(e, loadingId)
853
+ const sentCard: any = await sendPanelMarkdown(cardLines, (content) => e.reply(content))
854
+ rememberPanelCard(e, sentCard?.sent?.messageId)
855
+ cardSent = true
856
+ } catch (error: any) {
857
+ logger.debug("[QQ面板] 信息卡片单独发送失败(不影响选择表): " + String(error?.message ?? error))
858
+ }
783
859
  }
784
860
 
785
861
  // 按钮里只放短令牌,链接存在内存里(见 rememberPanelRequest 的说明);
@@ -936,7 +1012,12 @@ export async function sendQqParsePanel (e: Message, request: PanelRequest): Prom
936
1012
  */
937
1013
  if (runtime.config.qqPanelSourceLink !== false && request.url) lines.push(sourceLink('打开原站', request.url))
938
1014
  // 带链接发不出去时自动去掉链接行重发(不然整条面板、整个解析都会被一个链接拖死)
939
- await sendPanelMarkdown(lines, (content) => replaceLoadingTip(e, loadingId, content))
1015
+ const { sent } = await sendPanelMarkdown(lines, async (content) => {
1016
+ // 卡片已经撤过「加载中…」了;卡片没发出去(或渲染失败)时这里补撤一次
1017
+ if (!cardSent) await recallMessageById(e, loadingId)
1018
+ return await e.reply(content)
1019
+ })
1020
+ rememberPanelMessage(e, sent?.messageId)
940
1021
  logger.debug('[QQ面板] 已发送解析面板: ' + request.platform + ' ' + request.id + '(' + shown.length + '/' + info.options.length + ' 档画质)')
941
1022
  return true
942
1023
  }
@@ -284,20 +284,69 @@ export class Xiaohongshu extends Base {
284
284
  }
285
285
  }
286
286
 
287
+ /**
288
+ * 带「先提示、再宽限」的超时包装(和图片发送那边同一套口径,见 ImageSlice 的 SEND_* 常量)。
289
+ *
290
+ * 老写法是「到点就 Promise.race 出 null」—— 很多请求只是**慢**,不是挂了(小红书接口尤其如此),
291
+ * 早放弃的代价就是「评论区凭空少了内容」。现在分三段:
292
+ * noticeMs 只在日志里提示一句「还在拉,继续等」;
293
+ * hardMs 才怀疑它挂住;
294
+ * graceMs 再等一会儿那个迟到的结果,回来了就当成功。
295
+ *
296
+ * @returns 任务结果;真超时返回 null(调用方按「拿不到数据」处理即可)
297
+ */
298
+ const withNoticeTimeout = async <T>(
299
+ label: string,
300
+ task: Promise<T>,
301
+ options: { noticeMs: number; hardMs: number; graceMs: number }
302
+ ): Promise<T | null> => {
303
+ const timedOut = Symbol("timeout")
304
+ const startedAt = Date.now()
305
+ const seconds = (ms: number) => Math.round(ms / 1000)
306
+ const notice = setTimeout(() => {
307
+ logger.mark("[小红书] " + label + "还在进行(已 " + seconds(options.noticeMs) + "s),继续等 —— 接口只是慢,不代表失败")
308
+ }, options.noticeMs)
309
+ notice.unref?.()
310
+ /** 包一层:任务本身抛错时也要能让 Promise.race 正常结束,错由调用方的 try/catch 接住 */
311
+ const guarded: Promise<T | typeof timedOut> = task.catch((error: any) => { throw error })
312
+ const raced = await Promise.race([
313
+ guarded,
314
+ new Promise<typeof timedOut>((resolve) => {
315
+ const timer = setTimeout(() => resolve(timedOut), options.hardMs)
316
+ timer.unref?.()
317
+ })
318
+ ])
319
+ clearTimeout(notice)
320
+ if (raced !== timedOut) return raced
321
+ logger.mark("[小红书] " + label + "已 " + seconds(options.hardMs) + "s 没有返回,再等 " + seconds(options.graceMs) + "s 看它是挂住还是只是慢")
322
+ const graced = await Promise.race([
323
+ guarded,
324
+ new Promise<typeof timedOut>((resolve) => {
325
+ const timer = setTimeout(() => resolve(timedOut), options.graceMs)
326
+ timer.unref?.()
327
+ })
328
+ ])
329
+ if (graced !== timedOut) {
330
+ logger.mark("[小红书] " + label + "其实成功了(" + seconds(Date.now() - startedAt) + "s 才返回),这次不算超时")
331
+ return graced
332
+ }
333
+ logger.warn("[小红书] " + label + "超时(共 " + seconds(Date.now() - startedAt) + "s),本次跳过")
334
+ return null
335
+ }
336
+
287
337
  /**
288
338
  * 卡片发完后再去拉表情表(给评论区做表情转换用)。
289
339
  * 这里挂掉/超时都不影响已经发出去的卡片。
290
340
  */
291
341
  try {
292
- const EmojiList = await Promise.race([
293
- this.amagi.xiaohongshu.fetcher.fetchEmojiList(),
294
- new Promise((resolve) => setTimeout(() => resolve(null), 8000))
295
- ]) as any
342
+ const EmojiList = await withNoticeTimeout('表情表拉取', this.amagi.xiaohongshu.fetcher.fetchEmojiList(), {
343
+ noticeMs: 8000, hardMs: 30000, graceMs: 10000
344
+ }) as any
296
345
  if (EmojiList) {
297
346
  formattedEmojis = XiaohongshuEmoji(EmojiList.data)
298
347
  logger.mark('[小红书] 表情表已获取,共 ' + (Array.isArray(formattedEmojis) ? formattedEmojis.length : 0) + ' 条')
299
348
  } else {
300
- logger.warn('[小红书] 表情表超时(8s),评论区将不做表情转换')
349
+ logger.warn('[小红书] 表情表没拿到(超时),评论区将不做表情转换')
301
350
  }
302
351
  } catch (error: any) {
303
352
  logger.warn('[小红书] 表情表拉取失败(不影响卡片): ' + String(error?.message ?? error).slice(0, 100))
@@ -314,12 +363,12 @@ export class Xiaohongshu extends Base {
314
363
  /** 评论是否拉取失败 —— 失败也要把评论卡渲染出来(只是内容为空 + 一句提示) */
315
364
  let commentFailed = false
316
365
  try {
317
- // 评论同样加超时:上游这条链路本来就常挂,别把整条解析拖死
318
- CommentData = await Promise.race([
319
- this.fetchConfiguredNoteComments(data),
320
- new Promise((resolve) => setTimeout(() => resolve(null), 15000))
321
- ]) as any
322
- if (!CommentData) throw new Error('拉取评论超时(15s)')
366
+ // 评论同样加超时:上游这条链路本来就常挂,别把整条解析拖死。
367
+ // 但**「慢」不是「挂」**:15 秒只提示、60 秒才怀疑、再给 15 秒宽限(见 withNoticeTimeout)。
368
+ CommentData = await withNoticeTimeout('评论拉取', this.fetchConfiguredNoteComments(data), {
369
+ noticeMs: 15000, hardMs: 60000, graceMs: 15000
370
+ }) as any
371
+ if (!CommentData) throw new Error('拉取评论超时(提示 15s / 硬超时 60s + 宽限 15s 都没等到)')
323
372
  } catch (error: any) {
324
373
  /**
325
374
  * **这里绝对不能 return** —— 视频分支在这个代码块之后,
@@ -1053,7 +1053,7 @@ export const handlerError: React.FC<PosterProps<ApiErrorData>> = (props) => {
1053
1053
  {data.report?.group ?? '1050229473'}
1054
1054
  </p>
1055
1055
  <p className="text-lg break-all opacity-80" style={{ color: secondaryColor }}>
1056
- {data.report?.groupUrl ?? 'https://qm.qq.com/q/1050229473'}
1056
+ {data.report?.groupUrl ?? 'https://qm.qq.com/q/viymkIPvvq'}
1057
1057
  </p>
1058
1058
  </div>
1059
1059
  <div>
@@ -128,7 +128,7 @@ export interface ApiErrorData {
128
128
  url: string
129
129
  /** 反馈群号 */
130
130
  group: string
131
- /** 反馈群加群链接(https://qm.qq.com/q/群号) */
131
+ /** 反馈群加群链接(qm.qq.com 的分享链接;注意后面跟的是分享码,不是群号) */
132
132
  groupUrl: string
133
133
  }
134
134
  /** 是否为验证流程 */
package/src/qqFields.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "type": "boolean",
6
6
  "default": true,
7
7
  "label": "链接解析面板",
8
- "description": "QQ 里收到链接时先回一条带按钮的面板,让用户自己挑解析内容和画质,点一下才开始下载。关掉就直接按默认配置解析。"
8
+ "description": "有人在群里发链接时,机器人先回一条带按钮的面板,让他自己挑清晰度、要不要弹幕、要不要在线看。关掉就直接按配置里定的方式解析。"
9
9
  },
10
10
  {
11
11
  "key": "recallPanel",
@@ -13,7 +13,7 @@
13
13
  "type": "boolean",
14
14
  "default": true,
15
15
  "label": "自动撤回面板",
16
- "description": "用户在面板上点完按钮后,自动把上一条提示 / 选择消息撤回,群里不会堆着一排旧提示和选择。",
16
+ "description": "机器人每次发新消息前,先把上一条提示/面板撤掉,群里不会堆一排旧消息。默认打开。",
17
17
  "renderIn": "app"
18
18
  },
19
19
  {
@@ -24,7 +24,7 @@
24
24
  "min": 1,
25
25
  "max": 2000,
26
26
  "label": "画质档体积上限(MB)",
27
- "description": "面板里「清晰度」(= 发到 QQ)这一列的体积上限:超过它的画质档不给发送按钮,那一格标「超上限」。QQ 单个视频上限 200MB,选了也发不出去,所以默认 200。**在线播放模式下画质档位不再按这个值过滤**:视频根本不发到 QQ,所有档位都会列出来,超过这个值的档位只是不能发到 QQ,右边的「在线看」照常可用(它受「在线播放最大文件」约束)。"
27
+ "description": "面板里「清晰度」那一列的体积上限(MB)。超过它的画质档不给发送按钮,那一格显示「超上限」。QQ 单个视频最多 200MB,所以默认 200。在线播放模式下不按这个值过滤,所有档位都会列出来。"
28
28
  },
29
29
  {
30
30
  "key": "bangumiPanelCols",
@@ -34,7 +34,7 @@
34
34
  "min": 1,
35
35
  "max": 10,
36
36
  "label": "表格列数",
37
- "description": "番剧分集面板每行放几个集数,默认 5。"
37
+ "description": "番剧选集面板每行放几个集数,默认 5。"
38
38
  },
39
39
  {
40
40
  "key": "bangumiPanelRows",
@@ -44,7 +44,7 @@
44
44
  "min": 1,
45
45
  "max": 10,
46
46
  "label": "表格行数",
47
- "description": "番剧分集面板每页几行(含表头那一行),默认 4 行。列数 × 行数就是每页的集数。"
47
+ "description": "番剧选集面板每页放几行(含表头那一行),默认 4,也就是一页 20 集。"
48
48
  },
49
49
  {
50
50
  "key": "sliceImageOnDemand",
@@ -52,7 +52,7 @@
52
52
  "type": "boolean",
53
53
  "default": true,
54
54
  "label": "超大图片自动切片",
55
- "description": "长图(评论区那种)默认按普通图片发;只有超过 20MB 或者发送失败(拿不到消息 ID)时才自动切成几片,再用一条 markdown 拼回一整张。关掉就一律按普通图片发。"
55
+ "description": "卡片太长、超过 QQ 的图片上限时,自动切成几张发出去。默认打开。关掉就整张发,可能被 QQ 拒收。"
56
56
  },
57
57
  {
58
58
  "key": "sliceImageHeight",
@@ -62,7 +62,7 @@
62
62
  "min": 500,
63
63
  "max": 4000,
64
64
  "label": "切片高度(像素)",
65
- "description": "切片时每片的高度,默认 2000。越大片数越少、字越小;越小越清晰、片数越多。"
65
+ "description": "切片时每张图的高度(像素),默认 2000。数值越大张数越少,但单张也越大。"
66
66
  },
67
67
  {
68
68
  "key": "ocrApiKey",
@@ -71,7 +71,7 @@
71
71
  "default": "",
72
72
  "label": "卡片文字识别密钥",
73
73
  "secret": true,
74
- "description": "群里转发过来的分享卡片没有链接,只能靠截图识别出标题和作者再去搜索。填自己的免费密钥更稳定:https://ocr.space/ocrapi 。留空会用公共测试密钥,容易被限流。"
74
+ "description": "解析 B站分享卡片用(卡片里没有链接、只有封面图时要靠识别图上的字来找作品)。留空会用一个公共测试密钥,很容易被限流导致识别失败。可以到 ocr.space 免费申请一个填这里。"
75
75
  },
76
76
  {
77
77
  "key": "qqPanelDanmaku",
@@ -79,7 +79,7 @@
79
79
  "type": "boolean",
80
80
  "default": false,
81
81
  "label": "面板显示「烧录弹幕」列",
82
- "description": "只对**烧录模式**生效(通用里把「弹幕重定向在线播放器」关掉之后):打开后,QQ 里的解析面板会多一列「烧录弹幕」,用户点它就按那一档画质把弹幕烧进视频再发。在线播放模式下那一列由「弹幕重定向在线播放器」自己控制(开着就有),不需要在这里开。"
82
+ "description": "打开后解析面板会多一列「烧录弹幕」,用户可以选一档带弹幕的解析。在线播放模式下这一列会自动出现,不需要开这个。"
83
83
  },
84
84
  {
85
85
  "key": "qqPanelSourceLink",
@@ -87,7 +87,7 @@
87
87
  "type": "boolean",
88
88
  "default": true,
89
89
  "label": "面板带「打开原站」链接",
90
- "description": "打开后,解析面板(以及番剧选集面板)的下方会多一个「打开原站」按钮,点一下直接跳到抖音 / B站的原作品页;关掉就不显示。"
90
+ "description": "面板表格下面挂一个「打开原站」按钮,点它直接去平台看原视频。默认打开。"
91
91
  },
92
92
  {
93
93
  "key": "qqGroupFileLimitMB",
@@ -97,7 +97,7 @@
97
97
  "min": 0,
98
98
  "max": 2000,
99
99
  "label": "群文件发送阈值(MB)",
100
- "description": "视频体积超过这个值时,改用「群文件」发送。QQ 对富媒体视频会压缩、改名甚至丢掉后缀,走群文件才能原样发出去;填 0 表示不启用。"
100
+ "description": "视频超过这个体积就改用群文件发送(MB),默认 30。"
101
101
  },
102
102
  {
103
103
  "key": "forceNoDanmaku",
@@ -106,7 +106,7 @@
106
106
  "type": "boolean",
107
107
  "default": true,
108
108
  "label": "强制不烧录弹幕",
109
- "description": "优先级最高的总开关:打开时无论指令、面板按钮还是平台配置,都不会烧录弹幕,一律按纯视频解析。要用弹幕功能时先把它关掉。"
109
+ "description": "优先级最高的总开关。打开时无论指令、面板按钮还是平台配置都不会烧录弹幕,一律按纯视频解析。想用弹幕功能就先把它关掉。"
110
110
  },
111
111
  {
112
112
  "key": "parseDedupe",
@@ -115,7 +115,7 @@
115
115
  "type": "boolean",
116
116
  "default": true,
117
117
  "label": "短时间不重复解析",
118
- "description": "默认开启。同一条链接在下面设定的一段时间内只解析一次:QQ 的指令按钮是「发一条消息 + 再发一个交互事件」,用户连点、客户端重发也会把同一条链接投递两遍,跑两遍就是两次「开始解析」、两次下载、两次发送(第二次还常因为缓存已被清理而失败)。去重键包含会话、作品 ID、画质与弹幕开关,换画质、换作品、换群都不会被误伤。**关掉后每条消息都会解析**,需要反复调试同一条链接时可以临时关掉。"
118
+ "description": "同一条链接短时间里被重复发送时,只解析一次,避免刷屏和重复下载。默认打开。"
119
119
  },
120
120
  {
121
121
  "key": "parseDedupeMinutes",
@@ -126,7 +126,7 @@
126
126
  "min": 1,
127
127
  "max": 60,
128
128
  "label": "重复解析间隔(分钟)",
129
- "description": "上面那个开关打开时才生效:同一条链接在这段时间内只解析一次,默认 1 分钟(可填 1 ~ 60)。填得越大越省事(重复投递/连点不会再跑第二遍),但也意味着这段时间里你手动再发一次同一条链接会被忽略;想立刻重解析就把上面的开关关掉。"
129
+ "description": "上面「短时间不重复解析」的时间窗口,单位分钟,默认 2,可填 1 到 60。"
130
130
  },
131
131
  {
132
132
  "key": "playerEnabled",
@@ -137,7 +137,7 @@
137
137
  "type": "boolean",
138
138
  "default": true,
139
139
  "label": "在线播放器总开关",
140
- "description": "**这一组的总开关**,默认开启。打开时,解析面板里那一列按钮上的「弹幕」会把视频放到在线播放页(带弹幕)并回一条链接,不再把弹幕烧进视频、也不再把视频传到群里;链接有有效期,过期自动清理文件。开着时面板上还会多一列「在线看」(点它直接在线看,不管弹幕功能有没有开都带弹幕),这一列由下面的「面板显示「在线看」按钮」单独控制。**关掉它,这一组里其它设置都会变灰不可改**,解析也回到原来的 ffmpeg 烧录流程(面板列头变回「烧录弹幕」、不再有「在线看」列)。"
140
+ "description": "打开后视频不下载到群里,而是给一个网页链接在线看,可以带弹幕。默认打开。"
141
141
  },
142
142
  {
143
143
  "key": "playerWatchButton",
@@ -147,7 +147,7 @@
147
147
  "type": "boolean",
148
148
  "default": true,
149
149
  "label": "面板显示「在线看」按钮",
150
- "description": "默认开启。打开后,QQ 解析面板的「弹幕」列右边会多一列「在线看」:点它**直接在线播放**(带弹幕,视频不发到群里、也不占群文件),不看弹幕功能开没开 —— 在线看永远带弹幕。关掉就不显示这一列。注意:只有上面的「在线播放器总开关」开着时这一列才会出现(总开关关掉时这一项会变灰);播放器关掉时它没有任何作用。**超过「在线播放最大文件」的画质档不给「在线看」按钮**,那一格会标「超上限」。"
150
+ "description": "解析面板上多一列「在线看」,点它直接给播放链接,不看弹幕开关有没有打开。"
151
151
  },
152
152
  {
153
153
  "key": "playerBaseUrl",
@@ -157,7 +157,7 @@
157
157
  "type": "string",
158
158
  "default": "",
159
159
  "label": "播放器公网地址",
160
- "description": "**这是你自己的公网地址,插件猜不出来,必须由你填。**例如 https://play.example.com(结尾斜杠会自动去掉),用户收到的链接就是它加上 /kkk/player/<令牌>。留空时链接会退化成「http://本机 IP:端口」,**只有本机 / 内网能打开**;这时日志里会警告一次,机器人回复和播放页上也会带一句「未配置公网地址,仅本机可访问」——公网用户点开打不开是正常的,不是插件坏了。用 Nginx / Caddy 之类的反向代理指向下面的「播放器端口」即可,代理到 https 域名时这里就填那个域名。"
160
+ "description": "播放链接用的域名,例如 https://play.example.com。留空就只能本机或内网访问,群里的人打不开。"
161
161
  },
162
162
  {
163
163
  "key": "playerPort",
@@ -169,7 +169,7 @@
169
169
  "min": 0,
170
170
  "max": 65535,
171
171
  "label": "播放器端口",
172
- "description": "0 = 复用 Koishi 自己的端口(推荐,不用额外开端口);填其它值会用 node:http 另起一个服务。端口被占用时只记一条日志并退回 Koishi 端口,不会影响解析。注意别填浏览器禁止访问的端口(例如 6665-6669 这些 IRC 段),否则用户点开链接会直接 ERR_UNSAFE_PORT,看起来就像网页坏了。"
172
+ "description": "播放器单独用的端口,反向代理要转发到这个端口,默认 8888。填 0 或留空就跟着 Koishi 自己的端口走。"
173
173
  },
174
174
  {
175
175
  "key": "playerExpireMinutes",
@@ -181,7 +181,7 @@
181
181
  "min": 1,
182
182
  "max": 1440,
183
183
  "label": "链接有效期(分钟)",
184
- "description": "播放链接与视频文件的有效期,默认 60 分钟(最小 1、最大 1440)。到点后视频和弹幕会被自动删除,链接打开是「链接已过期」。"
184
+ "description": "播放链接的有效时间,单位分钟,默认 128(差不多两小时),最多 1440。过期后文件一起清理。"
185
185
  },
186
186
  {
187
187
  "key": "playerMaxFileMB",
@@ -193,7 +193,7 @@
193
193
  "min": 0,
194
194
  "max": 102400,
195
195
  "label": "在线播放最大文件(MB)",
196
- "description": "体积超过这个值的视频**不走在线播放**,改回原来的发送流程(免得把机器磁盘塞满)。留空或填 0 = 跟随全局:用上游的「文件大小限制」(usefilelimit / filelimit,当前默认 200MB);全局没开限制就是不限制。**这条上限对「超限转在线播放」一样有效**:开了转播、但视频比这条上限还大时,仍然按原来的方式处理(拒绝并说明),不会为了转播把机器磁盘塞满。判定发生在把文件搬进播放器目录之前,超限的视频一点都不会占播放器目录。"
196
+ "description": "超过这个体积的视频不走在线播放,避免把机器磁盘塞满(MB)。留空或填 0 表示跟随全局的「文件大小限制」。"
197
197
  },
198
198
  {
199
199
  "key": "playerOnOversize",
@@ -203,7 +203,7 @@
203
203
  "type": "boolean",
204
204
  "default": false,
205
205
  "label": "超限转在线播放",
206
- "description": "打开后,体积超过全局「文件大小限制」的视频不再被拒绝(原来会回一句「视频太大了,还是去B站看吧」),而是照常下载、改为在线播放:用户收到一条播放链接,视频不发到群里、也不占群文件。注意:**只有体积不超过上面「在线播放最大文件」的视频才会被转播**——比那个上限还大的照旧拒绝(免得把机器磁盘塞满),拒绝时会说明「超过在线播放的体积上限,按原来的方式处理」。关着时维持原来的行为。"
206
+ "description": "视频超过全局大小限制时不再直接拒绝,改成给一条在线播放链接。默认关闭。"
207
207
  },
208
208
  {
209
209
  "key": "forceOnlinePlayer",
@@ -213,7 +213,7 @@
213
213
  "type": "string",
214
214
  "default": "bilibili",
215
215
  "label": "强制在线播放的适配器",
216
- "description": "填**适配器平台名**(多个用逗号分隔,例如 bilibili 或 bilibili,onebot),**默认就是 bilibili**;留空表示不强制任何适配器。填了之后,**从这些适配器进来的消息**解析完一律只给在线播放链接:视频不发出去、也不占群文件,跟用户在面板上点的是「解析」还是「在线看」、体积有没有超限都无关。典型用法:B站私聊机器人(adapter-bilibili-dm)的平台名就是 bilibili,它发不了视频文件,把它填进去即可。注意这里填的是**适配器**的名字,不是解析来源平台(抖音 / B站 这些由「发送内容」那些开关管)。要求「在线播放器总开关」开着。"
216
+ "description": "填适配器平台名(默认 bilibili,也就是 B站私聊机器人)。从这里进来的消息解析完只给播放链接,不发视频文件。多个用逗号隔开,留空表示不强制。"
217
217
  },
218
218
  {
219
219
  "key": "errorReportUpload",
@@ -223,6 +223,16 @@
223
223
  "type": "boolean",
224
224
  "default": true,
225
225
  "label": "上传错误信息",
226
- "description": "插件出错时,自动把**错误信息、运行环境(Koishi 版本、装了哪些插件、适配器)、最近的日志**上传到收集站,站长在网页上就能查到,不用你截图、也不用翻日志文件。**默认开启**。上传失败不影响报错本身(错误卡片照常发)。不想要就在这里关掉。"
226
+ "description": "出错时自动把错误信息、运行环境和最近的日志上传到收集站,站长在网页上就能查,不用你截图。默认打开。"
227
+ },
228
+ {
229
+ "key": "errorNoCard",
230
+ "group": "通用",
231
+ "renderIn": "app",
232
+ "section": "错误上报",
233
+ "type": "boolean",
234
+ "default": false,
235
+ "label": "出错只发文字(不渲染错误卡片)",
236
+ "description": "出错时不渲染错误卡片,只在群里发一行文字:错误信息已上传,ID:xxx。上传照旧。默认关闭。"
227
237
  }
228
238
  ]
@@ -1,70 +0,0 @@
1
- /**
2
- * 控制台(Koishi 插件配置页)可见性冒烟测试。
3
- *
4
- * 需求:**kkk 的配置界面里所有设置项都要隐藏**,只留一段「请到 WebUI 面板改」的说明。
5
- * 理由:同一份配置在控制台改一半、在面板改一半,两边都会把整份配置写回 koishi.yml,
6
- * 很容易互相覆盖(用户实际遇到过「面板里关了、控制台一保存又回来了」)。
7
- *
8
- * 隐藏用的是 Koishi 自带的 `Schema.*.hidden()`(控制台客户端认 `meta.hidden`:
9
- * 不渲染、但字段仍在 schema 里,所以控制台保存不会把值弄丢)。
10
- *
11
- * 用法:node scripts/smoke-console-hidden.cjs
12
- */
13
- const path = require('node:path')
14
-
15
- const pluginRoot = path.resolve(__dirname, '..')
16
- const libRoot = path.join(pluginRoot, 'lib')
17
-
18
- const runtime = require(path.join(libRoot, 'compat', 'runtime.js'))
19
- const noop = () => {}
20
- runtime.bindRuntime({
21
- ctx: { get: () => undefined, logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }), bots: [], registry: new Map() },
22
- config: { app: {} },
23
- dataRoot: path.join(pluginRoot, 'data-smoke-console'),
24
- master: () => []
25
- })
26
-
27
- const { Config } = require(path.join(libRoot, 'index.js'))
28
-
29
- const results = []
30
- const check = (name, ok, detail) => {
31
- results.push({ name, ok })
32
- console.log((ok ? ' ✅ ' : ' ❌ ') + name + (detail ? ' —— ' + detail : ''))
33
- }
34
-
35
- /** 找出所有「可见」的叶子字段路径(hidden 的整棵子树都跳过) */
36
- const visibleLeaves = (node, prefix = '') => {
37
- if (!node || node.meta?.hidden) return []
38
- const out = []
39
- if (node.type === 'object') {
40
- for (const [key, child] of Object.entries(node.dict ?? {})) out.push(...visibleLeaves(child, prefix ? prefix + '.' + key : key))
41
- } else if (node.type === 'intersect' || node.type === 'union' || node.type === 'tuple') {
42
- for (const child of node.list ?? []) out.push(...visibleLeaves(child, prefix))
43
- } else {
44
- out.push(prefix || '(root)')
45
- }
46
- return out
47
- }
48
-
49
- const visible = visibleLeaves(Config)
50
-
51
- console.log('\n[1] 控制台里只剩一段指路文字')
52
- check('可见字段只有 webuiGuide', visible.length === 1 && visible[0] === 'webuiGuide', visible.join(', ') || '(没有任何可见字段)')
53
-
54
- console.log('\n[2] 那几组设置都还在 schema 里(隐藏 ≠ 删除,保存不会丢值)')
55
- const hiddenGroups = ['qq', 'advanced', 'forward', 'upstream']
56
- const branches = Config.type === 'intersect' ? Config.list : [Config]
57
- const dict = Object.assign({}, ...branches.map((branch) => branch.dict ?? {}))
58
- for (const key of hiddenGroups) {
59
- check('隐藏组仍然声明着:' + key, !!dict[key] && dict[key].meta?.hidden === true, dict[key] ? ('hidden=' + String(dict[key].meta?.hidden)) : '字段不存在')
60
- }
61
-
62
- console.log('\n[3] 指路文字写的是 WebUI 面板')
63
- const guide = dict.webuiGuide
64
- check('webuiGuide 可见', !!guide && guide.meta?.hidden !== true)
65
- check('说明里提到 /kkk 面板', /\/kkk/.test(guide?.meta?.description ?? ''), String(guide?.meta?.description ?? '').slice(0, 40) + '…')
66
- check('说明里写了「隐藏」这件事', /隐藏/.test(guide?.meta?.description ?? ''))
67
-
68
- const failed = results.filter((item) => !item.ok)
69
- console.log('\n=== ' + (results.length - failed.length) + '/' + results.length + ' 通过 ===')
70
- process.exitCode = failed.length ? 1 : 0