koishi-plugin-kkk 3.2.2 → 3.3.0-beta.2

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.
@@ -28,6 +28,8 @@ const node_url_1 = require("node:url");
28
28
  const node_fs_1 = __importDefault(require("node:fs"));
29
29
  const node_path_1 = __importDefault(require("node:path"));
30
30
  const koishi_1 = require("koishi");
31
+ // 版本比较复用注入器那边的实现(它处理了 -beta.1 这类预发布号的先后)
32
+ const semver_1 = require("../karin/module/utils/semver");
31
33
  const logger_1 = require("./logger");
32
34
  Object.defineProperty(exports, "logger", { enumerable: true, get: function () { return logger_1.logger; } });
33
35
  const forward_collect_1 = require("./forward-collect");
@@ -954,11 +956,21 @@ const checkPkgUpdate = async (name, _options) => {
954
956
  if (!response.ok)
955
957
  return { status: 'error', error: new Error('HTTP ' + response.status) };
956
958
  const meta = await response.json();
957
- const remote = meta?.['dist-tags']?.latest;
959
+ const tags = meta?.['dist-tags'] ?? {};
960
+ /**
961
+ * 预览版(3.3.0-beta.1 这种)**不能拿 latest 比**:本地跑 beta 时 latest 还在 3.2.2,
962
+ * 按老逻辑会报「有新版本 3.2.2」—— 那其实是**降级**。
963
+ * 所以带 - 的本地版本改看 beta 通道;beta 没有(说明预览已经并进正式版)才回退到 latest,
964
+ * 并且只在远端确实比本地新时才说「有更新」。
965
+ */
966
+ const isPreview = local.includes('-');
967
+ const remote = (isPreview ? (tags.beta || tags.latest) : tags.latest) || '';
958
968
  if (!remote)
959
969
  return { status: 'error', error: new Error('响应缺少 dist-tags.latest') };
960
970
  if (remote === local)
961
971
  return { status: 'no', local };
972
+ if (isPreview && !(0, semver_1.isSemverGreater)(remote, local))
973
+ return { status: 'no', local };
962
974
  return { status: 'yes', local, remote };
963
975
  }
964
976
  catch (error) {
package/lib/index.js CHANGED
@@ -135,11 +135,31 @@ exports.inject = {
135
135
  */
136
136
  /** 摊平后由「Koishi 原生设置」分组提供的字段 */
137
137
  const NATIVE_KEYS = ['masters', 'dataPath', 'debug', 'autoParse', 'webUiAuth'];
138
+ /**
139
+ * 控制台里**唯一可见**的一段文字:把用户指到 WebUI 面板去。
140
+ *
141
+ * 为什么把设置全藏了:同一份配置在控制台改一半、在面板改一半,两边都会把整份配置写回
142
+ * koishi.yml,很容易互相覆盖(用户实际遇到过「面板里关了、控制台一保存又回来了」)。
143
+ * 现在控制台不再提供编辑入口,只留这块说明。
144
+ *
145
+ * 注意:**隐藏不等于删除** —— 字段仍然在 schema 里声明着,所以在控制台里点保存
146
+ * 一样会把已有值带上,面板里的设置不会被清空。
147
+ */
148
+ const WEBUI_GUIDE = [
149
+ '**所有设置都在 WebUI 配置面板里改**,地址:/kkk(打开 Koishi 控制台后,左侧边栏也有一个「**kkk 配置**」入口)。',
150
+ '',
151
+ '面板里包含:接口库 / 通用(含**错误上报**、在线播放器、合并转发)/ 抖音 / 哔哩哔哩 / 快手 / 小红书 / **QQ 适配器** / 推送列表。' +
152
+ '改完点右下角保存即可 —— 会写回 koishi.yml 并热重载,**不用重启**。',
153
+ '',
154
+ '**Koishi 控制台里的设置项已经全部隐藏**(值仍然保留,在这里点保存也不会把它们弄丢),免得两边各改一半、互相覆盖。',
155
+ '',
156
+ '面板里还有一组「**Koishi 设置**」(数据目录、调试日志、自动解析、面板是否要求先登录控制台)。' +
157
+ '只有 masters(主人账号)没有面板入口 —— 它属于 Koishi 的权限体系,而面板是免登录页面,' +
158
+ '需要直接改 koishi.yml 里本插件的配置段(改完重启 Koishi)。'
159
+ ].join('\n');
138
160
  exports.Config = koishi_1.Schema.intersect([
139
161
  koishi_1.Schema.object({
140
- webuiGuide: koishi_1.Schema.const('').description('**本插件的配置都在配置面板里改**:Koishi 控制台左侧边栏 →「**kkk 配置**」。\n\n' +
141
- '(原版配置面板:接口库 / 通用 / 抖音 / 哔哩哔哩 / 快手 / 小红书 / **QQ 适配器** / 推送列表,' +
142
- '改完点右下角保存即可,写回 koishi.yml 并热重载,不用重启。)'),
162
+ webuiGuide: koishi_1.Schema.const('').description(WEBUI_GUIDE),
143
163
  // 全部隐藏:控制台表单里不显示,配置统一在控制台侧边栏的「kkk 配置」面板里改
144
164
  qq: (0, qqOptions_1.buildQqSchema)(koishi_1.Schema).hidden().description('QQ 适配器(只对 QQ 平台生效)'),
145
165
  advanced: koishi_1.Schema.object({
@@ -149,7 +169,7 @@ exports.Config = koishi_1.Schema.intersect([
149
169
  autoParse: koishi_1.Schema.boolean().default(true).description('群里有人发链接(或回复一条带链接的消息)就自动解析,不用打指令'),
150
170
  webUiAuth: koishi_1.Schema.boolean().default(true).description('配置面板 /kkk 是否要求先登录 Koishi 控制台(默认开)。装了 auth 插件的部署只有登录后才能打开面板;没装 auth 插件时本来就没有登录这一说,这里不生效'),
151
171
  }).collapse().hidden().description('Koishi 原生设置(一般不用改,已折叠)'),
152
- }),
172
+ }).description('配置入口:请在 WebUI 面板(/kkk)里修改'),
153
173
  koishi_1.Schema.object({
154
174
  /**
155
175
  * 合并转发(两级开关)。
@@ -184,7 +204,7 @@ exports.Config = koishi_1.Schema.intersect([
184
204
  xiaohongshu: koishi_1.Schema.boolean().default(false).description('小红书:单独打开合并转发(全局关着时生效)'),
185
205
  xiaohongshuContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
186
206
  .description('小红书转发合并哪些内容(留空 = 用全局那份)')
187
- }).description('合并转发(全局 + 各平台)'),
207
+ }).collapse().hidden().description('合并转发(全局 + 各平台)'),
188
208
  upstream: (0, schema_1.buildUpstreamSchema)(pluginRootDir).hidden().description('插件自身的配置(与 Karin 版 config.json 一致)。每项都带着上游默认值,枚举型字段是下拉框;' +
189
209
  '**与默认值不同**的项会在启动时写回 config.json,保持默认值的项不写(这样你直接改文件的内容不会被覆盖)')
190
210
  })
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resolveCardToUrl = exports.searchDouyinWorks = exports.searchBiliVideos = exports.extractUpNames = exports.extractUpName = exports.ocrImageText = exports.extractCardInfo = void 0;
3
+ exports.resolveCardToUrl = exports.searchBiliVideos = exports.extractUpNames = exports.extractUpName = exports.ocrImageText = exports.extractCardInfo = void 0;
4
4
  /**
5
5
  * QQ 卡片消息解析(小程序卡片 / 分享卡片)。
6
6
  *
@@ -19,7 +19,6 @@ exports.resolveCardToUrl = exports.searchDouyinWorks = exports.searchBiliVideos
19
19
  const node_crypto_1 = require("node:crypto");
20
20
  const node_karin_1 = require("../../../compat/node-karin.js");
21
21
  const runtime_1 = require("../../../compat/runtime");
22
- const amagiClient_1 = require("./amagiClient");
23
22
  const Config_1 = require("./Config");
24
23
  /** Wbi 签名的置换表(B站前端固定的那一份) */
25
24
  const MIXIN_KEY_ENC_TAB = [
@@ -441,55 +440,6 @@ const searchBiliVideos = async (keyword, title = '', author = '', limit = 8) =>
441
440
  }
442
441
  };
443
442
  exports.searchBiliVideos = searchBiliVideos;
444
- /* ------------------------------------------------------------------ *
445
- * 抖音搜索(直接用 amagi 的 search 端点)
446
- * ------------------------------------------------------------------ */
447
- const searchDouyinWorks = async (keyword, limit = 8, authors = []) => {
448
- try {
449
- // 这版接口库的抖音 fetcher 不一定有 search(实测 6.6.0 上没有),没有就干脆跳过
450
- const fetcher = amagiClient_1.douyinFetcher;
451
- if (typeof fetcher?.search !== 'function') {
452
- node_karin_1.logger.debug('[卡片解析] 当前接口库没有抖音搜索能力,跳过');
453
- return [];
454
- }
455
- const res = await fetcher.search({ query: String(keyword ?? '').trim(), type: 'video', number: limit });
456
- const list = res?.data?.data?.aweme_list ?? res?.data?.aweme_list ?? res?.aweme_list ?? [];
457
- const titleKey = normalizeText(keyword);
458
- const authorKeys = authors.map((item) => normalizeText(item)).filter((item) => item.length >= 2);
459
- return list
460
- .filter((item) => item?.aweme_id)
461
- .map((item, index) => {
462
- const desc = String(item.desc ?? '');
463
- const sim = titleSimilarity(normalizeText(desc), titleKey);
464
- // 作者对上一样加分:抖音搜索噪声大,光靠标题常常分不出是哪一条
465
- const authorKey = normalizeText(String(item.author?.nickname ?? ''));
466
- let authorScore = 0;
467
- for (const key of authorKeys) {
468
- if (!authorKey)
469
- break;
470
- if (authorKey === key) {
471
- authorScore = 60;
472
- break;
473
- }
474
- if (authorKey.includes(key) || key.includes(authorKey))
475
- authorScore = Math.max(authorScore, 35);
476
- }
477
- return {
478
- aweme_id: String(item.aweme_id),
479
- desc,
480
- author: String(item.author?.nickname ?? ''),
481
- score: Math.round(sim * 100) + authorScore - index
482
- };
483
- })
484
- .sort((left, right) => right.score - left.score)
485
- .slice(0, Math.max(1, Math.min(20, limit)));
486
- }
487
- catch (error) {
488
- node_karin_1.logger.warn('[卡片解析] 抖音搜索异常: ' + String(error?.message ?? error));
489
- return [];
490
- }
491
- };
492
- exports.searchDouyinWorks = searchDouyinWorks;
493
443
  const resolveCardToUrl = async (content) => {
494
444
  const card = (0, exports.extractCardInfo)(content);
495
445
  if (!card)
@@ -521,11 +471,7 @@ const resolveCardToUrl = async (content) => {
521
471
  node_karin_1.logger.mark('[卡片解析] OCR 没有给出可用关键词');
522
472
  return null;
523
473
  }
524
- /** 卡片自带的 source 字段最准(实测 `source: 哔哩哔哩`),其次是 OCR 文本 */
525
- const hint = String(card.source ?? '') + ' ' + card.title + ' ' + card.desc + ' ' + upName + ' ' + ocrText;
526
- const looksDouyin = /抖音|douyin|快手|ks\./i.test(hint);
527
- const looksBili = /bilibili|哔哩|B站|UP主/i.test(hint);
528
- // ③ 先按最可能的平台搜,命中就返回
474
+ // ③ B站搜一把:标题 + 作者都对上才自动继续,否则把候选交给用户挑
529
475
  const tryBili = async () => {
530
476
  const hits = await (0, exports.searchBiliVideos)(keyword, card.title, upNames, 8);
531
477
  const candidates = hits.slice(0, 6).map((item) => ({
@@ -540,30 +486,17 @@ const resolveCardToUrl = async (content) => {
540
486
  }
541
487
  return { candidates };
542
488
  };
543
- const tryDouyin = async () => {
544
- const hits = await (0, exports.searchDouyinWorks)(keyword || card.title, 8, upNames);
545
- const candidates = hits.slice(0, 6).map((item) => ({
546
- platform: 'douyin', id: item.aweme_id, title: item.desc, author: item.author, score: item.score
547
- }));
548
- const best = hits[0];
549
- // 抖音搜索噪声大,要求分数足够高才自动继续,否则交给用户挑
550
- if (best && best.score > 40)
551
- return { url: 'https://www.douyin.com/video/' + best.aweme_id, candidates };
552
- return { candidates };
553
- };
554
- const order = looksDouyin && !looksBili ? [tryDouyin, tryBili] : [tryBili, tryDouyin];
555
- const candidates = [];
556
- for (const attempt of order) {
557
- const hit = await attempt();
558
- if (hit) {
559
- candidates.push(...hit.candidates);
560
- if (hit.url) {
561
- node_karin_1.logger.mark('[卡片解析] 定位成功: ' + hit.url);
562
- // 提示语里报「真正匹配上的那个 UP 名」,而不是卡片摘要里那个不准的
563
- const matched = hit.matchedAuthor || upName;
564
- return { url: hit.url, platform: hit.url.includes('bilibili') ? 'bilibili' : 'douyin', card, candidates, ocrText, upName: matched };
565
- }
566
- }
489
+ /**
490
+ * **只搜 B站**:抖音没有卡片消息这种玩法(那条「当前接口库没有抖音搜索能力」的日志就是
491
+ * 以前多此一举地搜抖音留下的)。卡片解析这条链路只处理 B站卡片(入口处已经按 source 过滤过),
492
+ * 少搜一个平台就少一次风控风险、也少几秒等待。
493
+ */
494
+ const hit = await tryBili();
495
+ const candidates = hit.candidates;
496
+ if (hit.url) {
497
+ node_karin_1.logger.mark('[卡片解析] 定位成功: ' + hit.url);
498
+ // 提示语里报「真正匹配上的那个 UP 名」,而不是卡片摘要里那个不准的
499
+ return { url: hit.url, platform: 'bilibili', card, candidates, ocrText, upName: hit.matchedAuthor || upName };
567
500
  }
568
501
  node_karin_1.logger.mark('[卡片解析] 没能唯一定位(标题: ' + card.title + ',UP: ' + upName + '),候选 ' + candidates.length + ' 条');
569
502
  if (candidates.length) {
@@ -301,11 +301,17 @@ const errorHelpSegments = (report, platform) => {
301
301
  ? '错误信息已上传,ID:' + report.id
302
302
  : '错误信息上传失败(不影响使用)';
303
303
  const ask = '可以前往 QQ 群内寻找帮助,在群内发送错误 ID 或该错误图片';
304
+ /**
305
+ * 两段之间必须**空一行**(\n\n)。
306
+ *
307
+ * markdown 里单个换行不算换行:两段会被拼成一整行,QQ 那边按一行显示时字号会被撑得很难看
308
+ * (用户原话「不换行大小会崩」)。纯文本那条也一样留空行,两种平台的观感才对得上。
309
+ */
304
310
  if (officialQq) {
305
311
  // QQ 官方机器人认 markdown:群号做成可点的加群链接
306
- return [node_karin_1.segment.markdown(head + '\n' + '可以前往 [QQ 群 ' + group + '](' + link + ') 内寻找帮助,在群内发送错误 ID 或该错误图片')];
312
+ return [node_karin_1.segment.markdown(head + '\n\n' + '可以前往 [QQ 群 ' + group + '](' + link + ') 内寻找帮助,在群内发送错误 ID 或该错误图片')];
307
313
  }
308
314
  // 个人号(OneBot / NapCat)不渲染 markdown:裸链接 QQ 客户端自己会变蓝可点
309
- return [node_karin_1.segment.text(head + '\nQQ 群 ' + group + ':' + ask + '\n' + link)];
315
+ return [node_karin_1.segment.text(head + '\n\nQQ 群 ' + group + ':' + ask + '\n' + link)];
310
316
  };
311
317
  exports.errorHelpSegments = errorHelpSegments;
@@ -6,6 +6,8 @@ const module_1 = require("../../module");
6
6
  const ParseSteps_1 = require("../../module/utils/ParseSteps");
7
7
  // sendParseTip 单独导入:它在一个无依赖的叶子模块里,避免和平台模块形成循环 import
8
8
  const parseTip_1 = require("../../module/utils/parseTip");
9
+ // 注意用相对写法:@/ 别名在仓库里指向 karin/,@/player 会被解析成不存在的 karin/player
10
+ const player_1 = require("../../../player");
9
11
  const kuaishou_1 = require("../../platform/kuaishou");
10
12
  /**
11
13
  * 从作品详情里挑一条视频直链。
@@ -95,7 +97,7 @@ class Kuaishou extends module_1.Base {
95
97
  /** 发送任务组:评论区与视频各走一条线,谁先就绪谁先发 */
96
98
  /** 强制在线播放名单里的平台:本次一律走在线播放(见 player/index.ts) */
97
99
  // 适配器在「强制在线播放的适配器」名单里(例如 B站私聊机器人 platform === 'bilibili')
98
- applyForceOnlinePlayer(this.e);
100
+ (0, player_1.applyForceOnlinePlayer)(this.e);
99
101
  const sends = new ParseSteps_1.SendTasks(steps);
100
102
  await (0, parseTip_1.sendParseTip)(this.e, '快手');
101
103
  // 表情接口没换,还是 graphql 那条,`data.visionBaseEmoticons` 两层照旧
@@ -12,6 +12,8 @@ const date_fns_1 = require("date-fns");
12
12
  const node_karin_1 = require("../../../compat/node-karin.js");
13
13
  const node_karin_2 = require("../../../compat/node-karin.js");
14
14
  const module_1 = require("../../module");
15
+ // 注意用相对写法:@/ 别名在仓库里指向 karin/,@/player 会被解析成不存在的 karin/player
16
+ const player_1 = require("../../../player");
15
17
  const ParseSteps_1 = require("../../module/utils/ParseSteps");
16
18
  const Config_1 = require("../../module/utils/Config");
17
19
  const comments_1 = require("./comments");
@@ -53,7 +55,7 @@ class Xiaohongshu extends module_1.Base {
53
55
  /** 发送任务组:内容(详情卡片 / 图片)与视频各走一条线,谁先就绪谁先发 */
54
56
  /** 强制在线播放名单里的平台:本次一律走在线播放(见 player/index.ts) */
55
57
  // 适配器在「强制在线播放的适配器」名单里(例如 B站私聊机器人 platform === 'bilibili')
56
- applyForceOnlinePlayer(this.e);
58
+ (0, player_1.applyForceOnlinePlayer)(this.e);
57
59
  const sends = new ParseSteps_1.SendTasks(steps);
58
60
  // 诊断:把入参和每一步的结果打出来,定位「只提示不解析」卡在哪
59
61
  node_karin_2.logger.mark('[小红书] 开始解析: note_id=' + String(data?.note_id ?? '(空)') + ' xsec_token=' + (data?.xsec_token ? '有' : '(空)') + ' type=' + String(this.type));
package/lib/webui.js CHANGED
@@ -31,6 +31,29 @@ const configBridge_1 = require("./configBridge");
31
31
  const qqOptions_1 = require("./qqOptions");
32
32
  const COOKIE_NAME = 'kkk_config_token';
33
33
  const TOKEN_TTL = 7 * 24 * 60 * 60 * 1000;
34
+ /**
35
+ * 面板「Koishi 设置」分类里能改的项 —— 也就是 Koishi 侧的原生选项(koishi.yml 里本插件的顶层键)。
36
+ *
37
+ * **masters 不在这里**:主人账号是 Koishi 的权限体系(谁能执行主人指令、谁收报错),
38
+ * 面板是免登录页面,不该给它开这个口子 —— 仍然只在 koishi.yml 里改。
39
+ * 控制台那边也已经全部隐藏,所以这 4 项是唯一还有图形入口的地方。
40
+ */
41
+ const NATIVE_PANEL_KEYS = ['dataPath', 'debug', 'autoParse', 'webUiAuth'];
42
+ /** 面板要显示的原生设置(只取白名单,缺的用默认值兜底) */
43
+ const NATIVE_PANEL_DEFAULTS = {
44
+ dataPath: 'data',
45
+ debug: false,
46
+ autoParse: true,
47
+ webUiAuth: true
48
+ };
49
+ const readNativePanelOptions = (source) => {
50
+ const out = {};
51
+ for (const key of NATIVE_PANEL_KEYS) {
52
+ const value = source?.[key];
53
+ out[key] = value === undefined || value === null ? NATIVE_PANEL_DEFAULTS[key] : value;
54
+ }
55
+ return out;
56
+ };
34
57
  function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
35
58
  const server = ctx.server;
36
59
  if (!server || typeof server.get !== 'function') {
@@ -337,7 +360,8 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
337
360
  // 在 Koishi 这边它就存在 `upstream` 里,启动时同步进 config.json。
338
361
  // 另外附一份 `qq`(「QQ 适配器」分类):面板/切片/番剧选集/卡片识别这些是 Koishi 侧才有的开关,
339
362
  // 面板里作为独立分类显示,保存时由下面的 POST 拆出来写回 koishi.yml。
340
- ok(response, { ...(config?.upstream ?? {}), qq: (0, qqOptions_1.readQqOptions)(config) }, '');
363
+ // koishi 这一组是「Koishi 设置」分类:Koishi 侧的原生选项(masters 不在里面,见 NATIVE_PANEL_KEYS)
364
+ ok(response, { ...(config?.upstream ?? {}), qq: (0, qqOptions_1.readQqOptions)(config), koishi: readNativePanelOptions(config) }, '');
341
365
  });
342
366
  server.post('/kkk/v1/config', async (response) => {
343
367
  if (!authed(response))
@@ -350,10 +374,21 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
350
374
  // - `qq`(面板里的「QQ 适配器」分类)→ 写回插件自己的配置(koishi.yml)
351
375
  // - 其余整份 → upstream(Karin 版的 config.json 形状)
352
376
  // 保存都走 scope.update → 落盘 koishi.yml → 热重载 → 启动时同步回 config.json
353
- const { qq, ...upstream } = normalizeLists(body);
377
+ const { qq, koishi: nativeGroup, ...upstream } = normalizeLists(body);
378
+ // 「Koishi 设置」分类:只认白名单里的键(前端传什么都写不进来别的东西)
379
+ const native = {};
380
+ if (nativeGroup && typeof nativeGroup === 'object') {
381
+ for (const key of NATIVE_PANEL_KEYS) {
382
+ if (nativeGroup[key] === undefined)
383
+ continue;
384
+ native[key] = typeof NATIVE_PANEL_DEFAULTS[key] === 'boolean' ? !!nativeGroup[key] : String(nativeGroup[key]);
385
+ }
386
+ }
354
387
  await ctx.scope.update(normalize({
355
388
  ...(rawConfig ?? config),
356
389
  ...(qq && typeof qq === 'object' ? { qq: { ...(0, qqOptions_1.readQqOptions)(config), ...qq } } : {}),
390
+ // 注意:原生这几项是**顶层键**(dataPath / debug / autoParse / webUiAuth),不要塞进分组
391
+ ...native,
357
392
  upstream
358
393
  }));
359
394
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.2.2",
3
+ "version": "3.3.0-beta.2",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -244,6 +244,73 @@ const CATEGORY_ANCHOR = ',{key:' + q('pushlist') + ',label:' + q('推送列表')
244
244
  const SWITCH_ANCHOR = 'case' + q('pushlist') + ':return(0,U.jsx)(MR,{...e})'
245
245
  const COMPONENT_ANCHOR = ',PR=e=>{switch(e.activeFile){'
246
246
 
247
+ /* ------------------------------------------------------------------ *
248
+ * 「Koishi 设置」分类:插件在 Koishi 里的运行参数
249
+ * ------------------------------------------------------------------ */
250
+
251
+ const KOISHI_START = '/*KKK-KOISHI-START*/'
252
+ const KOISHI_END = '/*KKK-KOISHI-END*/'
253
+ const KOISHI_COMPONENT = 'KKKNativeConfig'
254
+ const KOISHI_CATEGORY = '{key:' + q('koishi') + ',label:' + q('Koishi 设置') + ',description:' + q('插件本体设置') + '}'
255
+ const KOISHI_SWITCH = ';case' + q('koishi') + ':return(0,U.jsx)(' + KOISHI_COMPONENT + ',{...e})'
256
+
257
+ /**
258
+ * 这一分类里的字段渲染器调用。
259
+ *
260
+ * 存的位置是 koishi.yml 里本插件的**顶层键**(dataPath / debug / autoParse / webUiAuth),
261
+ * 所以路径写 ['koishi', 字段名],服务端(src/webui.ts 的 NATIVE_PANEL_KEYS)拆出来写回顶层。
262
+ *
263
+ * **masters 不在这里**:主人账号属于 Koishi 的权限体系,而面板是免登录页面,
264
+ * 不该给它开口子 —— 仍然只在 koishi.yml 里改(控制台那边也全部隐藏了)。
265
+ */
266
+ const koishiFieldCode = [
267
+ 'n(' + q('Koishi 设置') + ',' + q('插件在 Koishi 里的运行参数。改完点右下角保存即可 —— 会写回 koishi.yml 并热重载,不用重启。') + ')',
268
+ 'r(' + q('基础') + ',(0,U.jsxs)(U.Fragment,{children:[' + [
269
+ 'i(' + arr(['koishi', 'autoParse']) + ',' + q('自动解析') + ',' + q('群里有人发链接(或回复一条带链接的消息)就自动解析,不用打指令。') + ')',
270
+ 'i(' + arr(['koishi', 'debug']) + ',' + q('调试日志') + ',' + q('在日志里输出调试信息,排查问题时才需要打开 —— 打开后日志会很吵。') + ')',
271
+ 'a(' + arr(['koishi', 'dataPath']) + ',' + q('数据目录') + ',' + q('配置、数据库、临时文件都放在这里(一般是绝对路径,例如 D:/devkoishi/data)。改完需要重启 Koishi 才生效。') + ',{type:' + q('text') + '})',
272
+ 'i(' + arr(['koishi', 'webUiAuth']) + ',' + q('面板需要登录控制台') + ',' + q('打开时 /kkk 面板要求先登录 Koishi 控制台(装了 auth 插件的部署)。没装 auth 插件时本来就没有登录这一说,这里不生效。') + ')'
273
+ ].join(',') + ']}))'
274
+ ].join(',')
275
+
276
+ const koishiComponentCode = KOISHI_COMPONENT
277
+ + '=({config:e,renderers:t})=>{let{renderPageHeader:n,renderSubSection:r,renderSwitch:i,renderTextField:a}=t;return(0,U.jsxs)(U.Fragment,{children:[' + koishiFieldCode + ']})}'
278
+
279
+ /** 去掉上次插入的 Koishi 分类片段(可反复执行) */
280
+ function stripKoishiCategory (text) {
281
+ let out = text
282
+ for (;;) {
283
+ const start = out.indexOf(KOISHI_START)
284
+ if (start < 0) break
285
+ const end = out.indexOf(KOISHI_END, start)
286
+ if (end < 0) break
287
+ const from = out[start - 1] === ',' ? start - 1 : start
288
+ out = out.slice(0, from) + out.slice(end + KOISHI_END.length)
289
+ }
290
+ out = out.split(',' + KOISHI_CATEGORY).join('')
291
+ out = out.split(KOISHI_SWITCH).join('')
292
+ return out
293
+ }
294
+
295
+ function patchKoishiCategory (text, name) {
296
+ if (!text.includes(KOISHI_COMPONENT) && !text.includes(',' + CATEGORY)) return text
297
+ let out = stripKoishiCategory(text)
298
+ // 分类入口:插在「QQ 适配器」之后
299
+ const categoryAnchor = ',' + CATEGORY
300
+ if (out.includes(categoryAnchor)) {
301
+ out = out.replace(categoryAnchor, categoryAnchor + ',' + KOISHI_CATEGORY)
302
+ } else {
303
+ console.warn('[kkk] ' + name + ':没找到「QQ 适配器」分类入口,跳过 Koishi 分类')
304
+ return text
305
+ }
306
+ // 分发 switch:插在 QQ 的 case 之后
307
+ if (out.includes(SWITCH)) out = out.replace(SWITCH, SWITCH + KOISHI_SWITCH)
308
+ // 组件定义:和 QQ 组件一样塞在 PR 前面
309
+ if (out.includes(COMPONENT_ANCHOR)) out = out.replace(COMPONENT_ANCHOR, ',' + KOISHI_START + koishiComponentCode + KOISHI_END + COMPONENT_ANCHOR)
310
+ console.log('[kkk] 「Koishi 设置」分类已注入: ' + name)
311
+ return out
312
+ }
313
+
247
314
  /** 去掉上次插入的组件(按标记切掉) */
248
315
  function stripComponent (text) {
249
316
  let out = text
@@ -802,6 +869,9 @@ for (const file of files) {
802
869
 
803
870
  // 3. 组件定义(塞在 PR 前面,同一个模块作用域,能用 U / Q 这些打包期变量)
804
871
  if (text.includes(COMPONENT_ANCHOR)) text = text.replace(COMPONENT_ANCHOR, ',' + START + componentCode + END + COMPONENT_ANCHOR)
872
+
873
+ // 4. 「Koishi 设置」分类(必须在 QQ 分类注入之后:它锚定的是 QQ 那一块)
874
+ text = patchKoishiCategory(text, name)
805
875
  }
806
876
 
807
877
  if (/DesktopLayout|MobileLayout/.test(name)) {
@@ -0,0 +1,70 @@
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
@@ -0,0 +1,93 @@
1
+ /**
2
+ * 「用了没 import」检查(针对平台处理器里的在线播放器接口)。
3
+ *
4
+ * 背景:线上真实故障 —— `kuaishou.ts` / `xiaohongshu.ts` 里调了
5
+ * `applyForceOnlinePlayer(this.e)`,但这两个文件**从来没 import 过它**,
6
+ * 一发快手 / 小红书链接就是:
7
+ *
8
+ * ReferenceError: applyForceOnlinePlayer is not defined
9
+ *
10
+ * 构建当时没拦住(用了未声明的名字,TypeScript 那条链路只做了转译)。
11
+ *
12
+ * 这里做的是**针对性**检查而不是通用 lint:把 `src/player` 导出的名字都收集起来,
13
+ * 再看平台处理器里有没有「调用了却没 import」的。通用 lint 在这个仓库里噪声太大
14
+ * (对象字面量、JSX 组件、同名局部函数都会误报),针对性检查零误报、直接锁住这个坑。
15
+ *
16
+ * 用法:node scripts/smoke-imports.cjs
17
+ */
18
+ const fs = require('node:fs')
19
+ const path = require('node:path')
20
+
21
+ const pluginRoot = path.resolve(__dirname, '..')
22
+ const srcRoot = path.join(pluginRoot, 'src')
23
+
24
+ const stripComments = (text) => text
25
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
26
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1 ')
27
+
28
+ const walk = (dir, filter) => {
29
+ const out = []
30
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
31
+ const full = path.join(dir, item.name)
32
+ if (item.isDirectory()) out.push(...walk(full, filter))
33
+ else if (filter(full)) out.push(full)
34
+ }
35
+ return out
36
+ }
37
+
38
+ /** src/player 导出的函数名 */
39
+ const playerFile = path.join(srcRoot, 'player', 'index.ts')
40
+ const playerText = fs.readFileSync(playerFile, 'utf8')
41
+ const playerExports = new Set()
42
+ for (const match of playerText.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) playerExports.add(match[1])
43
+ for (const match of playerText.matchAll(/export\s+const\s+([A-Za-z_$][\w$]*)/g)) playerExports.add(match[1])
44
+
45
+ /** 这个文件有没有从 player 模块 import 这个名字 */
46
+ const importsFromPlayer = (text, name) => {
47
+ // 注意用 [^{}]* 而不是 [\s\S]*?:后者会把前面好几条 import 一起吞进来,名字表就废了
48
+ // 也兼容 `import karin, { a, b } from '...'` 这种默认 + 具名混写
49
+ for (const match of text.matchAll(/import\s*(?:[A-Za-z_$][\w$]*\s*,)?\s*(?:type\s*)?\{([^{}]*)\}\s*from\s*'([^']*player[^']*)'/g)) {
50
+ const names = match[1].split(',').map((item) => item.trim().split(/\s+as\s+/).pop().trim())
51
+ if (names.includes(name)) return true
52
+ }
53
+ return false
54
+ }
55
+
56
+ const targets = walk(path.join(srcRoot, 'karin'), (file) => file.endsWith('.ts'))
57
+ const problems = []
58
+ let checked = 0
59
+ for (const file of targets) {
60
+ const text = stripComments(fs.readFileSync(file, 'utf8'))
61
+ for (const name of playerExports) {
62
+ const used = new RegExp('(?<![\\w$.])' + name + '\\s*\\(').test(text)
63
+ if (!used) continue
64
+ checked++
65
+ if (importsFromPlayer(text, name)) continue
66
+ // 本文件自己定义了同名函数(重新导出等)就不算漏
67
+ if (new RegExp('function\\s+' + name + '\\s*\\(').test(text)) continue
68
+ problems.push({ file: path.relative(srcRoot, file), name })
69
+ }
70
+ }
71
+
72
+ console.log('检查了 ' + targets.length + ' 个源文件,player 导出 ' + playerExports.size + ' 个名字,命中调用 ' + checked + ' 处')
73
+
74
+ // 自检:这个检查本身得真的能抓到(拿线上那次的形状验一下)
75
+ const fakeBad = "export const x = async () => { applyForceOnlinePlayer(this.e) }"
76
+ const fakeGood = "import { applyForceOnlinePlayer } from '../../../player'\nexport const x = async () => { applyForceOnlinePlayer(this.e) }"
77
+ const selfCheck = [{
78
+ name: '自检:漏 import 的写法必须判为问题',
79
+ ok: !importsFromPlayer(fakeBad, 'applyForceOnlinePlayer')
80
+ }, {
81
+ name: '自检:正常 import 的写法必须放行',
82
+ ok: importsFromPlayer(fakeGood, 'applyForceOnlinePlayer')
83
+ }]
84
+ for (const item of selfCheck) console.log((item.ok ? ' ✅ ' : ' ❌ ') + item.name)
85
+
86
+ const failed = problems.length > 0 || selfCheck.some((item) => !item.ok)
87
+ if (problems.length === 0) {
88
+ console.log('\n=== 通过:平台处理器里没有「调用了却没 import」的在线播放器接口 ===')
89
+ } else {
90
+ for (const item of problems) console.log(' ❌ ' + item.file + ' 调用了 ' + item.name + ' 但没 import')
91
+ console.log('\n=== 失败:' + problems.length + ' 处 ===')
92
+ }
93
+ process.exitCode = failed ? 1 : 0