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
package/lib/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
|
]
|
package/package.json
CHANGED
package/scripts/patch-webui.mjs
CHANGED
|
@@ -456,9 +456,9 @@ const TEXT_SWAPS = [
|
|
|
456
456
|
* 用户实测反馈「关闭合并转发 还是合并的」—— 之前这个开关只管「用谁的身份展示」,
|
|
457
457
|
* 关掉照样合并;现在它同时管「要不要合并」:打开才合并(触发者身份),关掉就逐条发。
|
|
458
458
|
*/
|
|
459
|
-
const FAKE_FORWARD_DESC = '
|
|
460
|
-
+ '
|
|
461
|
-
+ '
|
|
459
|
+
const FAKE_FORWARD_DESC = '全局合并转发,优先级最高:打开时所有平台的解析结果都合成一条聊天记录发出,用触发者的身份展示;'
|
|
460
|
+
+ '「开始解析 / 下载中」这类过程提示不会进去。关掉就不合并,内容一条一条发。'
|
|
461
|
+
+ '只对支持聊天记录的适配器有效果(QQ 官方适配器没有这个能力,会自动改成逐条发送)。'
|
|
462
462
|
|
|
463
463
|
const DESC_BY_LABEL = [
|
|
464
464
|
['谁可以触发扫码登录', PERM_DESC],
|
|
@@ -787,9 +787,9 @@ const FORWARD_END = '/*KKK-FORWARD-END*/'
|
|
|
787
787
|
const FORWARD_OPTIONS = '[' + [['text', '文字'], ['image', '图片'], ['video', '视频'], ['file', '文件']]
|
|
788
788
|
.map(([value, label]) => '{value:' + q(value) + ',label:' + q(label) + '}').join(',') + ']'
|
|
789
789
|
|
|
790
|
-
const FORWARD_SWITCH_DESC = '
|
|
791
|
-
const FORWARD_CONTENT_DESC = '
|
|
792
|
-
const FORWARD_GLOBAL_CONTENT_DESC = '
|
|
790
|
+
const FORWARD_SWITCH_DESC = '本平台单独打开合并转发。注意全局优先:通用里的「解析结果合并转发」打开时所有平台都会合并,这个开关开不开都一样;只有全局关着时它才起作用。默认关闭。'
|
|
791
|
+
const FORWARD_CONTENT_DESC = '合并转发里放哪些内容:没勾的会单独发出去、不进聊天记录。留空表示用通用里那份全局设置。视频体积大时有些适配器(比如 NapCat)会拒绝整条聊天记录,这时会自动改成单独发送,不会丢内容。'
|
|
792
|
+
const FORWARD_GLOBAL_CONTENT_DESC = '全局合并转发里放哪些内容(通用页那个开关打开时生效,所有平台共用):没勾的单独发。视频建议先不勾,聊天记录太大时适配器会整条拒绝。语音和 markdown 不在候选里:QQ 的聊天记录不支持语音气泡,markdown 只有官方 bot 认、而官方适配器没有合并转发能力。'
|
|
793
793
|
|
|
794
794
|
/** 平台页的锚点:紧跟在「解析开关」那一项之后插入 */
|
|
795
795
|
const FORWARD_TABS = [
|
|
@@ -813,7 +813,8 @@ function insertAfterCall (text, needle, code) {
|
|
|
813
813
|
/** 平台页里那一段:小标题 + 本平台开关 + 本平台内容多选 */
|
|
814
814
|
function forwardSectionCode (platform, label) {
|
|
815
815
|
return 't.renderSubSection(' + q('合并转发') + ',(0,U.jsxs)(U.Fragment,{children:['
|
|
816
|
-
|
|
816
|
+
// 分类名已经写着平台名了(哔哩哔哩页里再写一遍「合并转发(哔哩哔哩)」是重复)
|
|
817
|
+
+ 't.renderSwitch(' + arr([platform, 'forward']) + ',' + q('合并转发') + ',' + q(FORWARD_SWITCH_DESC) + ')'
|
|
817
818
|
+ ',t.renderCheckboxGroup(' + arr([platform, 'forwardContent']) + ',' + q('合并转发内容') + ',' + q(FORWARD_CONTENT_DESC) + ',' + FORWARD_OPTIONS + ')'
|
|
818
819
|
+ ']}))'
|
|
819
820
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 控制台(Koishi 插件配置页)字段冒烟测试。
|
|
3
|
+
*
|
|
4
|
+
* 用户要求:
|
|
5
|
+
* 1. **所有设置项都要在控制台里显示出来**(之前为了逼大家用面板,全部隐藏了,现在恢复);
|
|
6
|
+
* 2. 说明要**说人话**、是给用户看的;
|
|
7
|
+
* 3. 说明**不要用 markdown 语法**(控制台是按纯文本渲染的,写 **粗体** 只会看到一堆星号)。
|
|
8
|
+
*
|
|
9
|
+
* 用法:node scripts/smoke-console-fields.cjs
|
|
10
|
+
*/
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
|
|
13
|
+
const pluginRoot = path.resolve(__dirname, '..')
|
|
14
|
+
const libRoot = path.join(pluginRoot, 'lib')
|
|
15
|
+
const runtime = require(path.join(libRoot, 'compat', 'runtime.js'))
|
|
16
|
+
const noop = () => {}
|
|
17
|
+
runtime.bindRuntime({
|
|
18
|
+
ctx: { get: () => undefined, logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }), bots: [], registry: new Map() },
|
|
19
|
+
config: { app: {} },
|
|
20
|
+
dataRoot: path.join(pluginRoot, 'data-smoke-console'),
|
|
21
|
+
pluginRoot,
|
|
22
|
+
master: () => []
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const { Config } = require(path.join(libRoot, 'index.js'))
|
|
26
|
+
|
|
27
|
+
const results = []
|
|
28
|
+
const check = (name, ok, detail) => {
|
|
29
|
+
results.push({ name, ok })
|
|
30
|
+
console.log((ok ? ' ✅ ' : ' ❌ ') + name + (detail ? ' —— ' + detail : ''))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 收集所有字段:路径 / 是否 hidden / 说明 */
|
|
34
|
+
const walk = (node, prefix, out) => {
|
|
35
|
+
if (!node) return out
|
|
36
|
+
const hidden = node.meta?.hidden === true
|
|
37
|
+
if (node.type === 'object') {
|
|
38
|
+
for (const [key, child] of Object.entries(node.dict ?? {})) walk(child, prefix ? prefix + '.' + key : key, out)
|
|
39
|
+
} else if (node.type === 'intersect' || node.type === 'union' || node.type === 'tuple') {
|
|
40
|
+
for (const child of node.list ?? []) walk(child, prefix, out)
|
|
41
|
+
} else {
|
|
42
|
+
out.push({ path: prefix || '(root)', hidden, description: String(node.meta?.description ?? '') })
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
const fields = walk(Config, '', [])
|
|
47
|
+
|
|
48
|
+
console.log('\n[1] 设置项要能看见')
|
|
49
|
+
const hiddenCount = fields.filter((item) => item.hidden).length
|
|
50
|
+
check('没有隐藏任何字段', hiddenCount === 0, '隐藏 ' + hiddenCount + ' 个')
|
|
51
|
+
check('字段数量看起来正常(>100 项,含上游各平台配置)', fields.length > 100, fields.length + ' 个字段')
|
|
52
|
+
for (const key of ['qq.qqPanel', 'qq.playerEnabled', 'qq.errorReportUpload', 'qq.errorNoCard', 'advanced.masters', 'forward.global']) {
|
|
53
|
+
check('可见:' + key, fields.some((item) => item.path === key && !item.hidden))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('\n[1.5] 分组不要折叠(用户要求:一进来就能看到全部)')
|
|
57
|
+
const collapsed = []
|
|
58
|
+
const walkCollapse = (node, prefix) => {
|
|
59
|
+
if (!node) return
|
|
60
|
+
if (node.meta?.collapse === true) collapsed.push(prefix || '(root)')
|
|
61
|
+
if (node.type === 'object') for (const [key, child] of Object.entries(node.dict ?? {})) walkCollapse(child, prefix ? prefix + '.' + key : key)
|
|
62
|
+
else if (node.type === 'intersect' || node.type === 'union' || node.type === 'tuple') for (const child of node.list ?? []) walkCollapse(child, prefix)
|
|
63
|
+
}
|
|
64
|
+
walkCollapse(Config, '')
|
|
65
|
+
check('没有任何折叠分组', collapsed.length === 0, collapsed.join(', '))
|
|
66
|
+
|
|
67
|
+
console.log('\n[2] 说明说人话、不带 markdown 语法')
|
|
68
|
+
const MD_PATTERNS = [
|
|
69
|
+
['**加粗**', /\*\*/],
|
|
70
|
+
['反引号代码', /\x60/],
|
|
71
|
+
['markdown 链接', /\]\([^)]+\)/],
|
|
72
|
+
['标题井号', /(^|\s)#{1,6}\s/],
|
|
73
|
+
['markdown 表格', /\n\s*\|.*\|/]
|
|
74
|
+
]
|
|
75
|
+
const offenders = []
|
|
76
|
+
for (const item of fields) {
|
|
77
|
+
for (const [label, pattern] of MD_PATTERNS) {
|
|
78
|
+
if (pattern.test(item.description)) offenders.push(item.path + '(' + label + ')')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
check('没有任何字段说明带 markdown 语法', offenders.length === 0, offenders.slice(0, 6).join(', '))
|
|
82
|
+
|
|
83
|
+
console.log('\n[3] 常用设置每条都要有说明(用户看不懂就等于没配)')
|
|
84
|
+
const { QQ_FIELDS } = require(path.join(libRoot, 'qqOptions.js'))
|
|
85
|
+
const missing = QQ_FIELDS.filter((field) => !field.description || field.description.length < 8).map((field) => field.key)
|
|
86
|
+
check('常用设置的说明都不为空', missing.length === 0, missing.join(', '))
|
|
87
|
+
const tooLong = QQ_FIELDS.filter((field) => field.description.length > 200).map((field) => field.key + '(' + field.description.length + ')')
|
|
88
|
+
check('没有超长的「论文式」说明(<=200 字)', tooLong.length === 0, tooLong.join(', '))
|
|
89
|
+
|
|
90
|
+
const failed = results.filter((item) => !item.ok)
|
|
91
|
+
console.log('\n=== ' + (results.length - failed.length) + '/' + results.length + ' 通过 ===')
|
|
92
|
+
process.exit(failed.length ? 1 : 0)
|
|
@@ -85,7 +85,8 @@ amagi.default = function (options) {
|
|
|
85
85
|
|
|
86
86
|
const plugin = require(path.join(pluginRoot, 'lib/index.js'))
|
|
87
87
|
const ctx = new Context()
|
|
88
|
-
|
|
88
|
+
// parseDedupe: false —— 这个冒烟测试会拿同一条链接反复跑不同配置,去重会把后半段全部挡掉(不是被测逻辑出问题)
|
|
89
|
+
ctx.plugin(plugin, { dataPath: dataRoot, debug: true, qqPanel: true, qqFileLimitMB: 200, parseDedupe: false })
|
|
89
90
|
|
|
90
91
|
/* ------------------------------------------------------------------ *
|
|
91
92
|
* 断言工具
|
|
@@ -350,7 +351,8 @@ setTimeout(async () => {
|
|
|
350
351
|
* 一调用就 `ReferenceError: e is not defined` —— 错误卡片永远切不了片,
|
|
351
352
|
* 8.9MB 的长图直接原样发出去。
|
|
352
353
|
*/
|
|
353
|
-
|
|
354
|
+
// 旧的 sliceImageToMarkdown 早就换成 sliceImageToElements(返回元素数组,平台决定 markdown / 图片段)
|
|
355
|
+
const { sliceImageToElements } = require(path.join(pluginRoot, 'lib/karin/module/utils/ImageSlice.js'))
|
|
354
356
|
// 切片要上传,宿主的 assets 服务这里没有,直接塞一个假的
|
|
355
357
|
ctx.assets = { upload: async (data, name) => ({ url: 'https://example.com/' + name }) }
|
|
356
358
|
const { execFileSync } = require('node:child_process')
|
|
@@ -361,26 +363,27 @@ setTimeout(async () => {
|
|
|
361
363
|
let sliced = null
|
|
362
364
|
let sliceError = null
|
|
363
365
|
try {
|
|
364
|
-
|
|
366
|
+
// 平台传 'qq':切片只在 QQ 那条链路上生效(其它平台按原图发)
|
|
367
|
+
sliced = await sliceImageToElements(tallDataUri, 'qq')
|
|
365
368
|
} catch (error) {
|
|
366
369
|
sliceError = error
|
|
367
370
|
}
|
|
368
371
|
check('切片函数不抛错(ReferenceError 已修)', !sliceError, sliceError ? String(sliceError.message) : 'ok')
|
|
369
|
-
const slicedText = JSON.stringify(sliced
|
|
370
|
-
check('长图被切成多片 markdown',
|
|
372
|
+
const slicedText = JSON.stringify(sliced ?? '')
|
|
373
|
+
check('长图被切成多片 markdown', Array.isArray(sliced) && sliced.length >= 1 && (slicedText.match(/!\[#/g) || []).length >= 2,
|
|
371
374
|
JSON.stringify(slicedText.slice(0, 90)))
|
|
372
375
|
let badThrown = null
|
|
373
376
|
let badResult = 'unset'
|
|
374
377
|
try {
|
|
375
|
-
badResult = await
|
|
378
|
+
badResult = await sliceImageToElements('data:image/jpeg;base64,AAAA', 'qq')
|
|
376
379
|
} catch (error) {
|
|
377
380
|
badThrown = error
|
|
378
381
|
}
|
|
379
|
-
check('坏输入不抛错(交给调用方按原图发)', !badThrown && (badResult === null),
|
|
382
|
+
check('坏输入不抛错(交给调用方按原图发)', !badThrown && (badResult === null || (Array.isArray(badResult) && badResult.length === 0)),
|
|
380
383
|
badThrown ? String(badThrown.message) : String(badResult))
|
|
381
384
|
const handlerSource = fs.readFileSync(path.join(pluginRoot, 'lib/karin/module/utils/ErrorHandler/handler.js'), 'utf-8')
|
|
382
385
|
check('ErrorHandler 里仍有「切片失败按原图发送」的兜底',
|
|
383
|
-
handlerSource.includes('错误卡片切片失败,按原图发送') && /try\s*\{[\s\S]*
|
|
386
|
+
handlerSource.includes('错误卡片切片失败,按原图发送') && /try\s*\{[\s\S]*sliceImageToElements[\s\S]*?catch/.test(handlerSource))
|
|
384
387
|
|
|
385
388
|
console.log('\n[10] 「弹幕」与「在线看」按钮的合并规则(用户要求)')
|
|
386
389
|
{
|
package/src/index.ts
CHANGED
|
@@ -140,71 +140,68 @@ const NATIVE_KEYS = ['masters', 'dataPath', 'debug', 'autoParse', 'webUiAuth']
|
|
|
140
140
|
* 一样会把已有值带上,面板里的设置不会被清空。
|
|
141
141
|
*/
|
|
142
142
|
const WEBUI_GUIDE = [
|
|
143
|
-
'
|
|
143
|
+
'两种改法都行,改哪边都生效,改完记得点保存。',
|
|
144
144
|
'',
|
|
145
|
-
'
|
|
146
|
-
'改完点右下角保存即可 —— 会写回 koishi.yml 并热重载,**不用重启**。',
|
|
145
|
+
'一、就在这个页面改:下面的设置项都在,常用的有「是否发解析面板」「画质档体积上限」「超长图自动切片」「在线播放器」「错误上报」等。',
|
|
147
146
|
'',
|
|
148
|
-
'
|
|
147
|
+
'二、用配置面板改:打开 Koishi 控制台后,左侧边栏有一个「kkk 配置」入口,也可以直接访问 /kkk。',
|
|
148
|
+
'面板界面更直观,接口库、抖音、哔哩哔哩、快手、小红书、推送列表都在里面,改完点右下角保存,不用重启 Koishi。',
|
|
149
149
|
'',
|
|
150
|
-
'
|
|
151
|
-
'
|
|
152
|
-
'需要直接改 koishi.yml 里本插件的配置段(改完重启 Koishi)。'
|
|
150
|
+
'两边的说明和默认值都是同一份,不会出现「面板里有、控制台里没有」。' +
|
|
151
|
+
'面板是免登录页面,所以「主人账号」只能在控制台或 koishi.yml 里改。'
|
|
153
152
|
].join('\n')
|
|
154
153
|
|
|
155
154
|
export const Config: Schema<Config> = Schema.intersect([
|
|
156
155
|
Schema.object({
|
|
157
156
|
webuiGuide: Schema.const('').description(WEBUI_GUIDE),
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
/**
|
|
158
|
+
* 常用设置(面板 / 切片 / 在线播放器 / 错误上报)。
|
|
159
|
+
*
|
|
160
|
+
* 这里**不做嵌套分组**:Koishi 的插件配置表单是按 schema 的形状渲染的,
|
|
161
|
+
* 想在里面再分小节就得把值也改成嵌套结构 —— 而面板(/kkk)写回 koishi.yml 用的是扁平结构,
|
|
162
|
+
* 两边一旦不一致,控制台一保存就会把这些设置丢回默认值。
|
|
163
|
+
* 所以保持扁平,靠「字段顺序 + 每条说人话的说明」来保证可读性。
|
|
164
|
+
*/
|
|
165
|
+
qq: buildQqSchema(Schema).description('常用设置:面板、切片、在线播放器、错误上报'),
|
|
160
166
|
advanced: Schema.object({
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
167
|
+
masters: Schema.array(Schema.string()).default([])
|
|
168
|
+
.description('主人账号,填用户 ID(不是 QQ 号),例如 123456789。可以收到报错通知,也能执行只有主人能用的指令'),
|
|
169
|
+
dataPath: Schema.string().default('data')
|
|
170
|
+
.description('数据目录:配置、数据库、临时文件都放在这里。改完要重启 Koishi'),
|
|
171
|
+
debug: Schema.boolean().default(false)
|
|
172
|
+
.description('在日志里输出调试信息。排查问题时才需要打开,平时会很吵'),
|
|
173
|
+
autoParse: Schema.boolean().default(true)
|
|
174
|
+
.description('群里有人发链接(或者回复一条带链接的消息)就自动解析,不用打指令'),
|
|
175
|
+
webUiAuth: Schema.boolean().default(true)
|
|
176
|
+
.description('配置面板 /kkk 是否要求先登录 Koishi 控制台。装了 auth 插件的部署建议保持打开;没装 auth 插件时本来就没有登录这一步,这里不生效'),
|
|
177
|
+
}).description('Koishi 原生设置(一般不用改)'),
|
|
178
|
+
}),
|
|
168
179
|
Schema.object({
|
|
169
180
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* 面板(/kkk 的 SPA)是上游打包好的产物,加不了新字段,所以这里单独给一组能在
|
|
173
|
-
* **控制台**里改的表单:全局开关 + 每个平台各自的开关与「合并哪些内容」。
|
|
174
|
-
* 值写回 config.json 的对应位置(app.fakeForward / app.forwardContent / <平台>.forward …)。
|
|
181
|
+
* 合并转发(两级开关):值写回 config.json 的 app.fakeForward / app.forwardContent / <平台>.forward。
|
|
182
|
+
* 面板里也有同一组开关(见 scripts/patch-webui.mjs),改哪边都行,两边读的是同一份配置。
|
|
175
183
|
*/
|
|
176
184
|
forward: Schema.object({
|
|
177
|
-
guide: Schema.const('').description(
|
|
178
|
-
'**合并转发**:打开后,一次解析产生的所有内容会等解析全部结束、合并成一条转发发出;' +
|
|
179
|
-
'关掉就是一边解析一边逐条发。\n\n' +
|
|
180
|
-
'**全局优先**:上面的「全局」打开 → 所有平台都合并,下面的平台开关不再起作用;' +
|
|
181
|
-
'全局关着时,才轮到各平台自己的开关。\n\n' +
|
|
182
|
-
'**默认全部关闭**。\n\n' +
|
|
183
|
-
'⚠️ 合并转发开着时,视频这类大文件如果塞进转发节点,某些适配器(如 NapCat)会整条拒绝 —— ' +
|
|
184
|
-
'这时它会自动改成单独发送,不会丢内容。\n\n' +
|
|
185
|
-
'另外:**markdown 只有 QQ 官方 bot 支持**,OneBot(NapCat / Lagrange 等个人号)不渲染,' +
|
|
186
|
-
'所以那边图片一律按普通图片段发(切片、图集都是),配置里的 markdown 选项对它没有意义。'
|
|
187
|
-
),
|
|
188
185
|
global: Schema.boolean().default(false)
|
|
189
|
-
.description('
|
|
186
|
+
.description('全局合并转发。打开后所有平台都把一次解析的内容合成一条聊天记录发出;关着时下面各平台的开关才起作用。默认关闭'),
|
|
190
187
|
globalContent: Schema.array(Schema.union(['text', 'image', 'video', 'file'])).default([])
|
|
191
|
-
.description('
|
|
192
|
-
douyin: Schema.boolean().default(false).description('
|
|
188
|
+
.description('全局合并转发里放哪些内容:text 文字 / image 图片 / video 视频 / file 文件。没勾的单独直发,留空等于只放文字和图片。视频体积大时有些适配器(比如 NapCat)会拒绝整个聊天记录,这时会自动改成单独发送,不会丢内容'),
|
|
189
|
+
douyin: Schema.boolean().default(false).description('抖音:单独打开合并转发(全局关着时才起作用)'),
|
|
193
190
|
douyinContent: Schema.array(Schema.union(['text', 'image', 'video', 'file'])).default([])
|
|
194
|
-
.description('
|
|
195
|
-
bilibili: Schema.boolean().default(false).description('B
|
|
191
|
+
.description('抖音合并转发里放哪些内容,留空表示用全局那一份'),
|
|
192
|
+
bilibili: Schema.boolean().default(false).description('B站:单独打开合并转发(全局关着时才起作用)'),
|
|
196
193
|
bilibiliContent: Schema.array(Schema.union(['text', 'image', 'video', 'file'])).default([])
|
|
197
|
-
.description('B
|
|
198
|
-
kuaishou: Schema.boolean().default(false).description('
|
|
194
|
+
.description('B站合并转发里放哪些内容,留空表示用全局那一份'),
|
|
195
|
+
kuaishou: Schema.boolean().default(false).description('快手:单独打开合并转发(全局关着时才起作用)'),
|
|
199
196
|
kuaishouContent: Schema.array(Schema.union(['text', 'image', 'video', 'file'])).default([])
|
|
200
|
-
.description('
|
|
201
|
-
xiaohongshu: Schema.boolean().default(false).description('
|
|
197
|
+
.description('快手合并转发里放哪些内容,留空表示用全局那一份'),
|
|
198
|
+
xiaohongshu: Schema.boolean().default(false).description('小红书:单独打开合并转发(全局关着时才起作用)'),
|
|
202
199
|
xiaohongshuContent: Schema.array(Schema.union(['text', 'image', 'video', 'file'])).default([])
|
|
203
|
-
.description('
|
|
204
|
-
}).
|
|
205
|
-
upstream: buildUpstreamSchema(pluginRootDir).
|
|
206
|
-
'
|
|
207
|
-
'
|
|
200
|
+
.description('小红书合并转发里放哪些内容,留空表示用全局那一份')
|
|
201
|
+
}).description('合并转发:把一次解析的内容合成一条聊天记录发出'),
|
|
202
|
+
upstream: buildUpstreamSchema(pluginRootDir).description(
|
|
203
|
+
'插件配置:接口库(Cookie / 代理 / API 服务)、抖音 / B站 / 快手 / 小红书 的解析与推送、推送订阅列表。'
|
|
204
|
+
+ '每项都带默认值,枚举型是下拉框;和默认值不同的项会写回 config.json,保持默认值的不写(这样你直接改文件的内容不会被覆盖)'
|
|
208
205
|
)
|
|
209
206
|
})
|
|
210
207
|
])
|
|
@@ -708,6 +705,35 @@ function registerCommands (
|
|
|
708
705
|
logger.debug('卡片来源不是 B站(%s),跳过卡片解析', cardPlatform || '未知')
|
|
709
706
|
return next()
|
|
710
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* **同一条卡片消息被投递多遍时,只处理第一遍**。
|
|
710
|
+
*
|
|
711
|
+
* 线上实测:同一条 B站卡片会在几秒内进来两次(平台重投 / 另一个中间件再发一遍),
|
|
712
|
+
* 于是 OCR + 搜索各跑两遍、面板也可能发两条 —— 用户看到的就是「解析完了又解析一遍」。
|
|
713
|
+
*
|
|
714
|
+
* 去重键里的卡片正文要**先去掉 URL 的签名参数**:同一条卡片重投时图片链接会被重新签名,
|
|
715
|
+
* 直接拿原文哈希会认为是两条不同的消息(踩过这个坑,第二次照样 OCR)。
|
|
716
|
+
*/
|
|
717
|
+
const cardSignature = raw
|
|
718
|
+
.replace(/https?:\/\/[^\s"'<>]+/g, (url: string) => 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)',
|
|
729
|
+
String((session as any).messageId ?? '无'),
|
|
730
|
+
String((session as any).platform ?? '未知'))
|
|
731
|
+
const { acquireParseLock } = await import("./karin/module/utils/ParseLock")
|
|
732
|
+
const cardKey = ["card", String((session as any).platform ?? ""), String((session as any).channelId ?? ""), String((session as any).userId ?? ""), cardSignature].join(":")
|
|
733
|
+
if (!acquireParseLock(cardKey)) {
|
|
734
|
+
logger.debug("短时间内重复的同一条卡片消息,已忽略(不再重复 OCR/搜索): %s", cardKey.slice(0, 120))
|
|
735
|
+
return
|
|
736
|
+
}
|
|
711
737
|
const send = async (content: any) => {
|
|
712
738
|
try {
|
|
713
739
|
await (session as any).send(content)
|
|
@@ -4,6 +4,8 @@ import { logger, type Message } from 'node-karin'
|
|
|
4
4
|
import { getBuildMetadata } from '@/module'
|
|
5
5
|
import { EmojiReactionManager } from '@/module/utils/EmojiReaction'
|
|
6
6
|
|
|
7
|
+
// 注意路径只有四层 ..:ErrorHandler → utils → module → karin → src(多一层就指到仓库根了)
|
|
8
|
+
import { tryGetRuntime } from '../../../../compat/runtime'
|
|
7
9
|
import { uploadErrorReport, type ErrorReportResult } from '../ErrorReport'
|
|
8
10
|
import { platformOf, sliceImageToElements } from '../ImageSlice'
|
|
9
11
|
import { renderErrorImage } from './render'
|
|
@@ -61,17 +63,34 @@ export const handleBusinessError = async (
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
let img = await renderErrorImage(ctx)
|
|
65
66
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
67
|
+
* **「出错只发文字」开关**(面板 通用 → 错误上报 → 出错只发文字,配置项 errorNoCard)。
|
|
68
|
+
*
|
|
69
|
+
* 打开后:**不渲染错误卡片**(省掉十几秒渲染 + 一张几 MB 的大图),
|
|
70
|
+
* 只把「错误报告已上传,ID:xxx / 可以前往 QQ 群内寻找帮助」那几行发出去 —— **上报照旧**。
|
|
71
|
+
* 传空数组进去就行:下面几个发送函数本来就是「卡片 + 提示文案」拼一起发的(见 sender 的 errorHelpSegments)。
|
|
69
72
|
*/
|
|
73
|
+
let textOnly = false
|
|
70
74
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
textOnly = (tryGetRuntime()?.config as any)?.errorNoCard === true
|
|
76
|
+
} catch { /* 读不到配置就按老的来(渲染卡片) */ }
|
|
77
|
+
|
|
78
|
+
let img: any[] = []
|
|
79
|
+
if (textOnly) {
|
|
80
|
+
logger.mark('[ErrorHandler] 配置为「出错只发文字」,跳过错误卡片渲染(错误上报与提示文案照旧)')
|
|
81
|
+
} else {
|
|
82
|
+
img = await renderErrorImage(ctx)
|
|
83
|
+
/**
|
|
84
|
+
* 错误卡片也会超长(实测 2880×40000 / 45MB),直接发必被 QQ 拒收 ——
|
|
85
|
+
* 统一切片:官方 QQ 切成一条 markdown,OneBot 切成若干图片段(它不渲染 markdown),
|
|
86
|
+
* 失败就退回原图。
|
|
87
|
+
*/
|
|
88
|
+
try {
|
|
89
|
+
const sliced = await sliceImageToElements(img, platformOf(event))
|
|
90
|
+
if (sliced?.length) img = sliced
|
|
91
|
+
} catch (sliceError: any) {
|
|
92
|
+
logger.debug('[ErrorHandler] 错误卡片切片失败,按原图发送: ' + String(sliceError?.message ?? sliceError))
|
|
93
|
+
}
|
|
75
94
|
}
|
|
76
95
|
await sendErrorToTrigger(ctx, img)
|
|
77
96
|
await sendErrorToMaster(ctx, img)
|