koishi-plugin-kkk 3.3.1 → 3.3.3
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 +11 -0
- package/lib/index.js +27 -0
- package/lib/player/server.js +2 -1
- package/package.json +1 -1
- package/scripts/smoke-require.cjs +91 -0
- package/src/index.ts +29 -0
- package/src/player/server.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 3.3.3
|
|
4
|
+
|
|
5
|
+
### 同一条卡片消息不再解析两遍
|
|
6
|
+
|
|
7
|
+
- **卡片消息被重复投递时只处理第一遍**:QQ 会把同一次发送投递多遍(客户端重发、指令按钮 = 消息 + 交互事件),实测同一条 B站卡片会在几秒内进来两次 —— 于是 OCR + 搜索各跑两遍、面板也可能发两条,看起来就是「解析完了又解析一遍」。现在卡片这条链路在 OCR **之前**就去重(按「平台 + 频道 + 用户 + 卡片正文」),并且**先把图片链接的签名参数去掉再比**:同一条卡片重投时链接会重新签名,直接拿原文比会当成两条不同的消息。
|
|
8
|
+
- 顺带加了排查用的 debug 日志:收到卡片时会打 `收到卡片消息(messageId=…,平台=…)`,同一条卡片的两次投递 messageId 是否相同,一眼就能看出是平台重复投递还是别的插件又发了一遍。
|
|
9
|
+
|
|
10
|
+
## 3.3.2
|
|
11
|
+
|
|
12
|
+
> 修 3.3.1 的一个**致命笔误**:播放器模块里一行 import 多写了一层 `..`,导致插件在**加载时**就失败(`Cannot find module '../../compat/runtime'`),整个插件都起不来。构建链路只做转译不解析路径,所以当时没拦住 —— 现在补了一个「每个编译产物都 require 得动」的自测(scripts/smoke-require.cjs)。
|
|
13
|
+
|
|
3
14
|
## 3.3.1
|
|
4
15
|
|
|
5
16
|
> 3.3.0-beta.1 ~ beta.3 是给先行测试的人用的预览版,内容全部并进这一版;从 3.2.2 直接升上来就行。
|
package/lib/index.js
CHANGED
|
@@ -706,6 +706,33 @@ function registerCommands(ctx, logger, autoParse) {
|
|
|
706
706
|
logger.debug('卡片来源不是 B站(%s),跳过卡片解析', cardPlatform || '未知');
|
|
707
707
|
return next();
|
|
708
708
|
}
|
|
709
|
+
/**
|
|
710
|
+
* **同一条卡片消息被投递多遍时,只处理第一遍**。
|
|
711
|
+
*
|
|
712
|
+
* 线上实测:同一条 B站卡片会在几秒内进来两次(平台重投 / 另一个中间件再发一遍),
|
|
713
|
+
* 于是 OCR + 搜索各跑两遍、面板也可能发两条 —— 用户看到的就是「解析完了又解析一遍」。
|
|
714
|
+
*
|
|
715
|
+
* 去重键里的卡片正文要**先去掉 URL 的签名参数**:同一条卡片重投时图片链接会被重新签名,
|
|
716
|
+
* 直接拿原文哈希会认为是两条不同的消息(踩过这个坑,第二次照样 OCR)。
|
|
717
|
+
*/
|
|
718
|
+
const cardSignature = raw
|
|
719
|
+
.replace(/https?:\/\/[^\s"'<>]+/g, (url) => url.split("?")[0])
|
|
720
|
+
.replace(/\s+/g, " ")
|
|
721
|
+
.trim()
|
|
722
|
+
.slice(0, 2000);
|
|
723
|
+
/**
|
|
724
|
+
* 打一条「收到卡片」的日志(debug 级,排查重复投递用)。
|
|
725
|
+
*
|
|
726
|
+
* 同一条卡片连着来两次、而 **messageId 也相同** → 这条事件被重复投递/重复处理;
|
|
727
|
+
* messageId 不同 → 平台那边确实又发了一条。排查「到底谁重复了」就看它。
|
|
728
|
+
*/
|
|
729
|
+
logger.debug('收到卡片消息(messageId=%s,平台=%s)', String(session.messageId ?? '无'), String(session.platform ?? '未知'));
|
|
730
|
+
const { acquireParseLock } = await Promise.resolve().then(() => __importStar(require("./karin/module/utils/ParseLock")));
|
|
731
|
+
const cardKey = ["card", String(session.platform ?? ""), String(session.channelId ?? ""), String(session.userId ?? ""), cardSignature].join(":");
|
|
732
|
+
if (!acquireParseLock(cardKey)) {
|
|
733
|
+
logger.debug("短时间内重复的同一条卡片消息,已忽略(不再重复 OCR/搜索): %s", cardKey.slice(0, 120));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
709
736
|
const send = async (content) => {
|
|
710
737
|
try {
|
|
711
738
|
await session.send(content);
|
package/lib/player/server.js
CHANGED
|
@@ -64,7 +64,8 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
64
64
|
const node_http_1 = __importDefault(require("node:http"));
|
|
65
65
|
const node_path_1 = __importDefault(require("node:path"));
|
|
66
66
|
const node_karin_1 = require("../compat/node-karin.js");
|
|
67
|
-
|
|
67
|
+
// 注意只有一层 .. :src/player/server.ts → src/compat/runtime(写两层会变成根目录下的 compat,直接 MODULE_NOT_FOUND)
|
|
68
|
+
const runtime_1 = require("../compat/runtime");
|
|
68
69
|
const page_1 = require("./page");
|
|
69
70
|
const store_1 = require("./store");
|
|
70
71
|
/** 路由前缀 */
|
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「每个编译产物都 require 得动」冒烟测试。
|
|
3
|
+
*
|
|
4
|
+
* 起因(线上真实故障):给 player/server.ts 加了一行
|
|
5
|
+
* `import { tryGetRuntime } from '../../compat/runtime'` —— 多写了一层 `..`,
|
|
6
|
+
* 编译出来变成 `require("../../compat/runtime")`(指向插件根目录下的 compat,不存在)。
|
|
7
|
+
* TypeScript 那条链路只做转译、不解析路径,构建时毫无提示;**插件在用户机器上直接加载失败**:
|
|
8
|
+
*
|
|
9
|
+
* [W] config failed to load kkk
|
|
10
|
+
* Error: Cannot find module '../../compat/runtime'
|
|
11
|
+
*
|
|
12
|
+
* 这个测试就是把 lib 下每个 .js 都 require 一遍 —— 路径写错、模块顶层就抛异常,全都跑不掉。
|
|
13
|
+
*
|
|
14
|
+
* 用法:node scripts/smoke-require.cjs
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
const path = require('node:path')
|
|
18
|
+
const { createRequire } = require('node:module')
|
|
19
|
+
|
|
20
|
+
const pluginRoot = path.resolve(__dirname, '..')
|
|
21
|
+
const libRoot = path.join(pluginRoot, 'lib')
|
|
22
|
+
|
|
23
|
+
/** 绑定最小运行时:很多模块在顶层就读配置/日志 */
|
|
24
|
+
const runtime = require(path.join(libRoot, 'compat', 'runtime.js'))
|
|
25
|
+
const noop = () => {}
|
|
26
|
+
runtime.bindRuntime({
|
|
27
|
+
ctx: {
|
|
28
|
+
get: () => undefined,
|
|
29
|
+
logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }),
|
|
30
|
+
bots: [],
|
|
31
|
+
registry: new Map(),
|
|
32
|
+
on: noop,
|
|
33
|
+
middleware: noop
|
|
34
|
+
},
|
|
35
|
+
config: { app: {} },
|
|
36
|
+
dataRoot: path.join(pluginRoot, 'data-smoke-require'),
|
|
37
|
+
pluginRoot,
|
|
38
|
+
master: () => []
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const walk = (dir) => {
|
|
42
|
+
const out = []
|
|
43
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
44
|
+
const full = path.join(dir, item.name)
|
|
45
|
+
if (item.isDirectory()) out.push(...walk(full))
|
|
46
|
+
else if (item.name.endsWith('.js')) out.push(full)
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const req = createRequire(path.join(libRoot, 'index.js'))
|
|
52
|
+
const files = walk(libRoot)
|
|
53
|
+
const failures = []
|
|
54
|
+
let ok = 0
|
|
55
|
+
let esm = 0
|
|
56
|
+
/** 缺第三方依赖(宿主没装)的模块:单独列出,不算失败 */
|
|
57
|
+
const optional = []
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
try {
|
|
60
|
+
req(file)
|
|
61
|
+
ok++
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const message = String(error?.message ?? error)
|
|
64
|
+
/** 模板类模块是 ESM(用 import 加载的),require 报这个是**预期**的,不算失败 */
|
|
65
|
+
const isEsm = error?.code === 'ERR_REQUIRE_ESM' || error?.code === 'ERR_REQUIRE_ASYNC_MODULE'
|
|
66
|
+
|| /Cannot use import statement outside a module|require\(\) of ES Module/.test(message)
|
|
67
|
+
if (isEsm) { esm++; continue }
|
|
68
|
+
/**
|
|
69
|
+
* 缺**第三方包**(cors / node:sqlite 之类)不算这次要抓的问题:有些模块是动态 import 的可选路径,
|
|
70
|
+
* 依赖装没装取决于宿主环境。**相对路径**找不到才是真问题(就是这次线上的那种),照旧算失败。
|
|
71
|
+
*/
|
|
72
|
+
const missing = /Cannot find module '([^']+)'/.exec(message)
|
|
73
|
+
if (missing && !missing[1].startsWith('.')) {
|
|
74
|
+
optional.push(path.relative(libRoot, file) + ' → 缺依赖 ' + missing[1])
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
failures.push({ file: path.relative(libRoot, file), message })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('检查了 ' + files.length + ' 个编译产物:require 成功 ' + ok + ' 个,ESM(预期跳过)' + esm + ' 个,缺第三方依赖(预期跳过)' + optional.length + ' 个')
|
|
82
|
+
for (const item of optional) console.log(' · ' + item)
|
|
83
|
+
if (failures.length === 0) {
|
|
84
|
+
console.log('\n=== 通过:每个模块都加载得动(没有写错的 import 路径 / 顶层异常) ===')
|
|
85
|
+
// 用 process.exit:require 这些模块时可能有别的东西把 exitCode 置成了非 0
|
|
86
|
+
process.exit(0)
|
|
87
|
+
} else {
|
|
88
|
+
for (const item of failures) console.log(' ❌ ' + item.file + ' → ' + item.message.split('\n')[0])
|
|
89
|
+
console.log('\n=== 失败:' + failures.length + ' 个模块加载不了 ===')
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -708,6 +708,35 @@ function registerCommands (
|
|
|
708
708
|
logger.debug('卡片来源不是 B站(%s),跳过卡片解析', cardPlatform || '未知')
|
|
709
709
|
return next()
|
|
710
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* **同一条卡片消息被投递多遍时,只处理第一遍**。
|
|
713
|
+
*
|
|
714
|
+
* 线上实测:同一条 B站卡片会在几秒内进来两次(平台重投 / 另一个中间件再发一遍),
|
|
715
|
+
* 于是 OCR + 搜索各跑两遍、面板也可能发两条 —— 用户看到的就是「解析完了又解析一遍」。
|
|
716
|
+
*
|
|
717
|
+
* 去重键里的卡片正文要**先去掉 URL 的签名参数**:同一条卡片重投时图片链接会被重新签名,
|
|
718
|
+
* 直接拿原文哈希会认为是两条不同的消息(踩过这个坑,第二次照样 OCR)。
|
|
719
|
+
*/
|
|
720
|
+
const cardSignature = raw
|
|
721
|
+
.replace(/https?:\/\/[^\s"'<>]+/g, (url: string) => url.split("?")[0])
|
|
722
|
+
.replace(/\s+/g, " ")
|
|
723
|
+
.trim()
|
|
724
|
+
.slice(0, 2000)
|
|
725
|
+
/**
|
|
726
|
+
* 打一条「收到卡片」的日志(debug 级,排查重复投递用)。
|
|
727
|
+
*
|
|
728
|
+
* 同一条卡片连着来两次、而 **messageId 也相同** → 这条事件被重复投递/重复处理;
|
|
729
|
+
* messageId 不同 → 平台那边确实又发了一条。排查「到底谁重复了」就看它。
|
|
730
|
+
*/
|
|
731
|
+
logger.debug('收到卡片消息(messageId=%s,平台=%s)',
|
|
732
|
+
String((session as any).messageId ?? '无'),
|
|
733
|
+
String((session as any).platform ?? '未知'))
|
|
734
|
+
const { acquireParseLock } = await import("./karin/module/utils/ParseLock")
|
|
735
|
+
const cardKey = ["card", String((session as any).platform ?? ""), String((session as any).channelId ?? ""), String((session as any).userId ?? ""), cardSignature].join(":")
|
|
736
|
+
if (!acquireParseLock(cardKey)) {
|
|
737
|
+
logger.debug("短时间内重复的同一条卡片消息,已忽略(不再重复 OCR/搜索): %s", cardKey.slice(0, 120))
|
|
738
|
+
return
|
|
739
|
+
}
|
|
711
740
|
const send = async (content: any) => {
|
|
712
741
|
try {
|
|
713
742
|
await (session as any).send(content)
|
package/src/player/server.ts
CHANGED
|
@@ -23,7 +23,8 @@ import path from 'node:path'
|
|
|
23
23
|
|
|
24
24
|
import { logger } from 'node-karin'
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// 注意只有一层 .. :src/player/server.ts → src/compat/runtime(写两层会变成根目录下的 compat,直接 MODULE_NOT_FOUND)
|
|
27
|
+
import { tryGetRuntime } from '../compat/runtime'
|
|
27
28
|
|
|
28
29
|
import { renderExpiredPage, renderPlayerPage } from './page'
|
|
29
30
|
import {
|