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/CHANGELOG.md +50 -0
- package/assets/web/assets/index-BRROK0vh.js +1 -1
- package/lib/index.js +69 -43
- package/lib/karin/module/utils/ErrorHandler/handler.js +29 -9
- package/lib/karin/module/utils/ErrorReport.js +10 -3
- package/lib/karin/module/utils/ImageSlice.js +53 -7
- package/lib/karin/module/utils/QqPanel.js +93 -3
- package/lib/karin/platform/xiaohongshu/xiaohongshu.js +56 -11
- package/lib/ktr/template/other/handlerError/components/handlerError.js +1 -1
- package/lib/qqFields.json +33 -23
- package/package.json +1 -1
- package/scripts/patch-webui.mjs +8 -7
- package/scripts/smoke-console-fields.cjs +92 -0
- package/scripts/smoke-qqpanel.cjs +11 -8
- package/src/index.ts +72 -46
- package/src/karin/module/utils/ErrorHandler/handler.ts +27 -8
- package/src/karin/module/utils/ErrorReport.ts +10 -2
- package/src/karin/module/utils/ImageSlice.ts +53 -7
- package/src/karin/module/utils/QqPanel.ts +84 -3
- package/src/karin/platform/xiaohongshu/xiaohongshu.ts +60 -11
- package/src/ktr/template/other/handlerError/components/handlerError.tsx +1 -1
- package/src/ktr/template/other/handlerError/components/types.ts +1 -1
- package/src/qqFields.json +33 -23
- package/scripts/smoke-console-hidden.cjs +0 -70
|
@@ -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
|
-
/**
|
|
87
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
713
|
+
/**
|
|
714
|
+
* **卡片单独发一条**(用户要求:不要和「选第几集」的表格挤在一起),
|
|
715
|
+
* 并且不记进「上一条面板」—— 翻页 / 选集时只撤表格。
|
|
716
|
+
* 翻页会重新渲染卡片,所以这里先撤掉上一张卡片,免得翻几次堆一排(见 recallLastPanelCard)。
|
|
717
|
+
*/
|
|
718
|
+
const lines: string[] = []
|
|
719
|
+
try {
|
|
720
|
+
const cardLines = [""]
|
|
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 的图片必须写成 
|
|
782
|
-
|
|
849
|
+
const cardLines = [""]
|
|
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) =>
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
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('[小红书]
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (!CommentData) throw new Error('
|
|
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/
|
|
1056
|
+
{data.report?.groupUrl ?? 'https://qm.qq.com/q/viymkIPvvq'}
|
|
1057
1057
|
</p>
|
|
1058
1058
|
</div>
|
|
1059
1059
|
<div>
|
package/src/qqFields.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"type": "boolean",
|
|
6
6
|
"default": true,
|
|
7
7
|
"label": "链接解析面板",
|
|
8
|
-
"description": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|