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/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
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.3.2",
3
+ "version": "3.4.0",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -456,9 +456,9 @@ const TEXT_SWAPS = [
456
456
  * 用户实测反馈「关闭合并转发 还是合并的」—— 之前这个开关只管「用谁的身份展示」,
457
457
  * 关掉照样合并;现在它同时管「要不要合并」:打开才合并(触发者身份),关掉就逐条发。
458
458
  */
459
- const FAKE_FORWARD_DESC = '**全局合并转发(优先级最高)**:打开时所有平台的解析结果都合并成一条转发消息发出,'
460
- + '转发用触发者身份展示;过程提示(开始解析 / 下载中 / 发送中…)不会进转发。**关掉就不合并**,'
461
- + '内容像以前一样一条一条发。只有支持合并转发的适配器有效果(QQ 官方适配器没有这个能力,会自动退化成逐条发送)。'
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 = '合并转发里包含哪些内容:没勾选的内容会**单独直发**,不进转发节点。留空 = 用通用里那份全局设置。提示:视频体积大时有些适配器(如 NapCat)会拒绝整个转发节点,这时会自动改成单独发送,不会丢内容。'
792
- const FORWARD_GLOBAL_CONTENT_DESC = '全局合并转发里包含哪些内容(通用页这个开关打开时生效,所有平台共用):没勾选的内容单独直发。视频类内容建议先不勾——转发节点太大的话适配器会整条拒绝。语音和 markdown 不在候选里:QQ 的聊天记录不支持语音气泡,markdown 只有官方 bot 认、而官方适配器没有合并转发能力。'
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
- + 't.renderSwitch(' + arr([platform, 'forward']) + ',' + q('合并转发(' + label + ')') + ',' + q(FORWARD_SWITCH_DESC) + ')'
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
- ctx.plugin(plugin, { dataPath: dataRoot, debug: true, qqPanel: true, qqFileLimitMB: 200 })
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
- const { sliceImageToMarkdown } = require(path.join(pluginRoot, 'lib/karin/module/utils/ImageSlice.js'))
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
- sliced = await sliceImageToMarkdown(tallDataUri)
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?.children?.map((child) => child.attrs?.content ?? '').join('') ?? '')
370
- check('长图被切成多片 markdown', /!\[#400px #/i.test(slicedText) && (slicedText.match(/!\[#/g) || []).length >= 2,
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 sliceImageToMarkdown('data:image/jpeg;base64,AAAA')
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]*sliceImageToMarkdown[\s\S]*?catch/.test(handlerSource))
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
- '**所有设置都在 WebUI 配置面板里改**,地址:/kkk(打开 Koishi 控制台后,左侧边栏也有一个「**kkk 配置**」入口)。',
143
+ '两种改法都行,改哪边都生效,改完记得点保存。',
144
144
  '',
145
- '面板里包含:接口库 / 通用(含**错误上报**、在线播放器、合并转发)/ 抖音 / 哔哩哔哩 / 快手 / 小红书 / **QQ 适配器** / 推送列表。' +
146
- '改完点右下角保存即可 —— 会写回 koishi.yml 并热重载,**不用重启**。',
145
+ '一、就在这个页面改:下面的设置项都在,常用的有「是否发解析面板」「画质档体积上限」「超长图自动切片」「在线播放器」「错误上报」等。',
147
146
  '',
148
- '**Koishi 控制台里的设置项已经全部隐藏**(值仍然保留,在这里点保存也不会把它们弄丢),免得两边各改一半、互相覆盖。',
147
+ '二、用配置面板改:打开 Koishi 控制台后,左侧边栏有一个「kkk 配置」入口,也可以直接访问 /kkk。',
148
+ '面板界面更直观,接口库、抖音、哔哩哔哩、快手、小红书、推送列表都在里面,改完点右下角保存,不用重启 Koishi。',
149
149
  '',
150
- '面板里还有一组「**Koishi 设置**」(数据目录、调试日志、自动解析、面板是否要求先登录控制台)。' +
151
- '只有 masters(主人账号)没有面板入口 —— 它属于 Koishi 的权限体系,而面板是免登录页面,' +
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
- // 全部隐藏:控制台表单里不显示,配置统一在控制台侧边栏的「kkk 配置」面板里改
159
- qq: buildQqSchema(Schema).hidden().description('QQ 适配器(只对 QQ 平台生效)'),
157
+ /**
158
+ * 常用设置(面板 / 切片 / 在线播放器 / 错误上报)。
159
+ *
160
+ * 这里**不做嵌套分组**:Koishi 的插件配置表单是按 schema 的形状渲染的,
161
+ * 想在里面再分小节就得把值也改成嵌套结构 —— 而面板(/kkk)写回 koishi.yml 用的是扁平结构,
162
+ * 两边一旦不一致,控制台一保存就会把这些设置丢回默认值。
163
+ * 所以保持扁平,靠「字段顺序 + 每条说人话的说明」来保证可读性。
164
+ */
165
+ qq: buildQqSchema(Schema).description('常用设置:面板、切片、在线播放器、错误上报'),
160
166
  advanced: Schema.object({
161
- masters: Schema.array(Schema.string()).default([]).description('主人账号(用户 ID,例如 123456789):接收报错通知,以及执行只有主人能用的指令'),
162
- dataPath: Schema.string().default('data').description('数据目录:配置、数据库、临时文件都放在这里'),
163
- debug: Schema.boolean().default(false).description('在日志里输出调试信息,排查问题时才需要打开'),
164
- autoParse: Schema.boolean().default(true).description('群里有人发链接(或回复一条带链接的消息)就自动解析,不用打指令'),
165
- webUiAuth: Schema.boolean().default(true).description('配置面板 /kkk 是否要求先登录 Koishi 控制台(默认开)。装了 auth 插件的部署只有登录后才能打开面板;没装 auth 插件时本来就没有登录这一说,这里不生效'),
166
- }).collapse().hidden().description('Koishi 原生设置(一般不用改,已折叠)'),
167
- }).description('配置入口:请在 WebUI 面板(/kkk)里修改'),
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('全局转发里合并哪些内容:text 文字 / image 图片 / video 视频 / file 文件。留空 = 用默认(text、image)。**语音和 markdown 不在候选里**:QQ 的聊天记录不支持语音气泡,markdown 只有官方 bot 认而官方适配器没有合并转发能力;没列出来的内容单独直发'),
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
- }).collapse().hidden().description('合并转发(全局 + 各平台)'),
205
- upstream: buildUpstreamSchema(pluginRootDir).hidden().description(
206
- '插件自身的配置(与 Karin 版 config.json 一致)。每项都带着上游默认值,枚举型字段是下拉框;' +
207
- '**与默认值不同**的项会在启动时写回 config.json,保持默认值的项不写(这样你直接改文件的内容不会被覆盖)'
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
- * 错误卡片也会超长(实测 2880×40000 / 45MB),直接发必被 QQ 拒收 ——
67
- * 统一切片:官方 QQ 切成一条 markdown,OneBot 切成若干图片段(它不渲染 markdown),
68
- * 失败就退回原图。
67
+ * **「出错只发文字」开关**(面板 通用 → 错误上报 → 出错只发文字,配置项 errorNoCard)。
68
+ *
69
+ * 打开后:**不渲染错误卡片**(省掉十几秒渲染 + 一张几 MB 的大图),
70
+ * 只把「错误报告已上传,ID:xxx / 可以前往 QQ 群内寻找帮助」那几行发出去 —— **上报照旧**。
71
+ * 传空数组进去就行:下面几个发送函数本来就是「卡片 + 提示文案」拼一起发的(见 sender 的 errorHelpSegments)。
69
72
  */
73
+ let textOnly = false
70
74
  try {
71
- const sliced = await sliceImageToElements(img, platformOf(event))
72
- if (sliced?.length) img = sliced
73
- } catch (sliceError: any) {
74
- logger.debug('[ErrorHandler] 错误卡片切片失败,按原图发送: ' + String(sliceError?.message ?? sliceError))
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)