koishi-plugin-kkk 3.11.0 → 3.12.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 +70 -258
- package/lib/build-metadata.json +5 -5
- package/lib/karin/apps/tools.js +46 -3
- package/lib/karin/module/utils/CardImageCache.js +76 -3
- package/package.json +1 -1
- package/scripts/probe-extract-once.cjs +317 -0
- package/scripts/verify-changelog-range.cjs +26 -0
- package/src/karin/apps/tools.ts +57 -4
- package/src/karin/module/utils/CardImageCache.ts +79 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,263 +1,75 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
-
## 3.
|
|
4
|
-
|
|
5
|
-
###
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
###
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
-
|
|
72
|
-
|
|
73
|
-
—— 长图卡片本身已经发过一遍了,再挂个按钮去重发它没有意义。
|
|
74
|
-
四个平台(B站 / 抖音 / 快手 / 小红书)统一走这个口径,原生按钮和 markdown 文字链两条路都走。
|
|
75
|
-
抖音以前连 `commentPics` 都不记,按钮只能退回去重发长图 —— 现在补上了。
|
|
76
|
-
- 「是否收集评论区的图片」**真的接上了**:打开时解析完直接把评论区里用户贴的图**发一条**
|
|
77
|
-
(优先合并成一条 markdown:官方 QQ bot 上合并转发经常发不出去,会退化成一张图一条消息;
|
|
78
|
-
不支持 markdown 的适配器才退回 `makeForward`),
|
|
79
|
-
**并且不再挂「提取评论区图片」按钮** —— 图已经在群里了,再挂一个只是重复发一遍。
|
|
80
|
-
关着(默认)就照旧:不直接发,按钮挂上。
|
|
81
|
-
- 新增 `src/karin/module/utils/CommentPics.ts`:把「评论区图片直接发」收成**一个**函数,
|
|
82
|
-
返回「**到底发出去了没有**」,调用方拿这个结果决定按钮挂不挂。
|
|
83
|
-
三个调用点(B站视频 / B站动态 / 抖音)原本各抄一份,而这次的规则要横跨「发送」和「挂按钮」两处,
|
|
84
|
-
抄三份必然会漏改一处,出现「发了图还挂按钮」或「没发图也没按钮」这种半截状态。
|
|
85
|
-
- 发失败(没图 / 图全部落地失败 / 适配器报错)**不算发过**,按钮照挂 —— 别让用户没路走。
|
|
86
|
-
- 图**直接发过之后仍然记进缓存**:按钮不挂只是「别再点重发」,手敲 `kkk评论` 还要读这份缓存。
|
|
87
|
-
- 面板上那个开关的说明也跟着改了(`scripts/patch-webui.mjs` 里加了一条描述改写):
|
|
88
|
-
「打开后:…卡片下面就不再挂『提取评论区图片』按钮了;关闭(默认):…想单独存图再点一下。」
|
|
89
|
-
免得用户再对着文案猜它到底管什么。
|
|
90
|
-
- `scripts/probe-extract-buttons.cjs` 重写:覆盖「只有封面 / 有评论图 / 只有评论长图 / 没有作品 id」
|
|
91
|
-
四种缓存状态下四个平台按钮的挂与不挂、开关打开后的按钮抑制,
|
|
92
|
-
以及 `sendCommentPicsDirectly` 的四条出口(没图 / 落地失败 / 正常两张 / 发送抛错),
|
|
93
|
-
外加一条**源码守卫**(产物里不许再出现 `if (false && Config.…)` 这种短路,且开关真的被读到)。
|
|
94
|
-
全程离线(探针自己起 `127.0.0.1` 服务供图),28 项全过。
|
|
95
|
-
|
|
96
|
-
### 所有指令收进 `kkk` 分组(控制台里不再铺一地)
|
|
97
|
-
|
|
98
|
-
- 以前 40 条指令全是**顶层**的(`解析`、`kkk帮助`、`b站登录`、`设置抖音推送`、
|
|
99
|
-
`抖音全部强制推送`…),控制台的「指令」页从头翻到尾。
|
|
100
|
-
- 现在统一挂在 **`kkk` 分组**下:`kkk.解析`、`kkk.帮助`、`kkk.版本`、`kkk.B站登录`、
|
|
101
|
-
`kkk.设置抖音推送`、`kkk.下载进度`…… 共 34 条子指令 + 1 个分组节点。
|
|
102
|
-
- Koishi 的指令树是**靠名字里的 `.` 分词**的(`ctx.command('kkk.帮助')` 会顺手建出父指令 `kkk`),
|
|
103
|
-
而控制台只把**名字里带 `.`** 的子指令收进 `children` ——
|
|
104
|
-
所以「只把 `parent` 指过去、名字还叫 `kkk帮助`」是没用的,名字必须真的写成 `kkk.xxx`。
|
|
105
|
-
- 显示名会**去掉前导的 `kkk`**(`kkk帮助` → `帮助`、`kkkB站登录` → `B站登录`),
|
|
106
|
-
免得控制台里出现 `kkk/kkk帮助` 这种套娃。
|
|
107
|
-
- **老名字一个都没少,而且不能少**:用户习惯、QQ 按钮里的指令文本(`解析 … --qn=16`)、
|
|
108
|
-
面板上的 📊 按钮(`下载进度`)用的都是裸名字,所以全部原样挂成**别名**。
|
|
109
|
-
`解析` / `kkk解析` / `弹幕解析` 这类本来就指向同一个 handler 的名字会**合并成一条指令**
|
|
110
|
-
(`解析` 与 `kkk解析` 现在是同一条),控制台里不再重复出现。
|
|
111
|
-
- 四种写法都能用:`解析 <链接>`、`/解析 <链接>`、`kkk.解析 <链接>`、`kkk 解析 <链接>`。
|
|
112
|
-
后两种是分组带来的新写法,`action` 里会把 `kkk` + 分隔符(空格 / 点 / 全角空格)
|
|
113
|
-
还原成 karin 注册表认的那个名字再往下传(移植过来的业务代码按
|
|
114
|
-
`e.msg.replace(/^#kkk帮助/, '')` 这种裸名字写的,不能改一行)。
|
|
115
|
-
- 只敲 `kkk` 会列出所有子指令(父指令上挂了个 action),子指令照常优先。
|
|
116
|
-
- **顺手修**:面板上那个 📊「查询下载进度」按钮走的是**回调按钮**(`interaction/button`),
|
|
117
|
-
回调文本进的是文本兜底(`runTextCommand`),而 `下载进度` 从来就不是 karin 注册表里的指令 ——
|
|
118
|
-
也就是说**点了没反应**。现在文本兜底里单独认一下 `#?(kkk)?下载进度`。
|
|
119
|
-
- `alias()` 撞名会抛异常、把整个 `apply` 拖死(apply 一挂,指令和控制台路由全都上不去)。
|
|
120
|
-
正常升级路径上旧指令会先被 dispose 掉、撞不上,但热重载时序偶尔会让上一个实例的指令还在表里,
|
|
121
|
-
所以挂别名包了一层 try/catch:撞名只警告(重启即恢复),绝不让 apply 抛出去。
|
|
122
|
-
|
|
123
|
-
### md 图片的尺寸改成「真量」;量不到就不发 markdown(修「图片被强制拉伸」)
|
|
124
|
-
|
|
125
|
-
用户先后反馈过两件事,根因是同一个:**尺寸是猜出来的**。
|
|
126
|
-
|
|
127
|
-
1. 「发送的所有 md 图片都要 `#宽px #高px`,不然根本看不见」——
|
|
128
|
-
QQ 的 markdown 图片必须写死尺寸,`` 在手机端是**一片空白**。
|
|
129
|
-
2. 「自适应发送的图片长宽高有些被强制拉伸了」——
|
|
130
|
-
读不出尺寸时以前会**猜**一个 `宽度 × 宽度×1.3` 的框填进去,
|
|
131
|
-
而 QQ 是**按声明的框渲染**的:框的比例和原图对不上就被硬拉变形。
|
|
132
|
-
|
|
133
|
-
现在统一成一句话:**尺寸必须来自真实测量,量不到就返回 `null`、退回普通图片段发**
|
|
134
|
-
(普通图片会被客户端压得糊一点,但比例是对的、也看得见,比一张拉变形的图强)。
|
|
135
|
-
|
|
136
|
-
- 新增 `src/compat/imageSize.ts`:一个**无依赖**的叶子模块,从二进制头读 PNG / JPEG / GIF / WebP
|
|
137
|
-
的真实像素尺寸(含 WebP 的 `VP8X` / `VP8L` / `VP8 ` 三种头)。
|
|
138
|
-
以前「读尺寸」在插件里有**四个消费方各写各的**,能力还不一样 —— 同一张 WebP
|
|
139
|
-
在一条链路上能读出来、在另一条上读不出来,读不出来的那条只能猜。
|
|
140
|
-
现在四条链路共用它,格式覆盖一次就全都覆盖。
|
|
141
|
-
- `Render.getImageMetadata` 从「只认 PNG / JPEG」换成它;
|
|
142
|
-
`compat/imageMarkdown.ts` 里那份重复的读取实现删掉;
|
|
143
|
-
`QqPanel` 的面板卡片尺寸也走它。
|
|
144
|
-
- **删掉所有「读不出就猜」的兜底**:
|
|
145
|
-
- `QqPanel.toMarkdownImage` 原来有**四处**「读不出尺寸就按 `maxWidth × maxWidth×1.3` 估一个」
|
|
146
|
-
(这就是拉伸的来源)。现在:读不出 → 先补一次 ffprobe → 还是不行就 `null`;
|
|
147
|
-
宿主的 `assets` 服务没给出公网地址、图片下载失败,同样 `null`。
|
|
148
|
-
- `QqPanel.panelCardInfo` 新增:面板卡片的 `#宽px #高px` 只用真实值,
|
|
149
|
-
读不出就**不发这张卡**(不再 `|| 1440` / `|| 1080` 兜一个)。
|
|
150
|
-
- 发送出口的兜底(`compat/imageMarkdown`)也跟着改:
|
|
151
|
-
- `ensureMarkdownImageSize` 保留为**估算版**,只在「连图都读不到」时当最后一道兜底;
|
|
152
|
-
- 新增 `sizeMarkdownImages`:**先把图下下来量真实尺寸**,量到就用真实比例,
|
|
153
|
-
量不到才退到估算。走的是它,不是估算那版。
|
|
154
|
-
- 这层兜底要**放在「有没有图片段」的判断之前**:长图切片那种消息整条只有
|
|
155
|
-
markdown、一个图片段都没有,放后面会整条漏掉。
|
|
156
|
-
markdown 段的正文在 `children[].attrs.content`(`segment.markdown` 走 satori 的 `h()`),
|
|
157
|
-
不在 `attrs.content` 上,两种形状都认(改写函数因此变成 async)。
|
|
158
|
-
- `scripts/probe-md-image-size.cjs`:探针重写成**离线可重复**的
|
|
159
|
-
(自己起一个 `127.0.0.1` 的服务提供确定尺寸的图),
|
|
160
|
-
覆盖「800×600 要缩成 420×315 而不是 420×546」「读不出尺寸 / 404 / base64 / data:
|
|
161
|
-
一律返回 null」「量不到才用估算」等 39 项。
|
|
162
|
-
|
|
163
|
-
### 封面解析也要去重
|
|
164
|
-
|
|
165
|
-
- 出面板(B站 / 抖音那条「封面 + 画质表格」)原来是在作品级去重**之前**就 return 的,
|
|
166
|
-
于是短时间内重复发同一条链接会一遍遍重新出面板;
|
|
167
|
-
反倒**点清晰度按钮是有去重的**(按钮命令带 `--qn` / `--q`,绕过面板走到作品级去重)。
|
|
168
|
-
- 现在首发链接也按「平台 + 会话 + 用户 + 作品 id」挡一次,**画质不在键里**
|
|
169
|
-
(选了画质走下面那道作品级去重,两者互不干扰)。
|
|
170
|
-
- 面板按钮点出来的(带 `--p` / `--qn` / `--panel` / `--bgp`)一律放行,
|
|
171
|
-
否则「重发面板」「上一页 / 下一页」会被它自己上一次的点击挡住。
|
|
172
|
-
|
|
173
|
-
### 卡片下面加「提取封面图 / 提取评论区图片」
|
|
174
|
-
|
|
175
|
-
- 卡片在 QQ 上是一整条 markdown 图片,几张图叠在一起,想单独存封面或只看评论区长图很不方便。
|
|
176
|
-
现在卡片下面多一行两个按钮,点一下把那张图**单独发一遍**(md 格式,不重新渲染卡片)。
|
|
177
|
-
- 按钮挂在**评论区那条长图**下面(详情卡片下面也有):QQ 上的解析基本都是
|
|
178
|
-
「发链接 → 出面板 → 点清晰度」,这种走法详情卡片是不发的(面板已经发过那张卡),
|
|
179
|
-
评论区才是每次都能看到的那条。
|
|
180
|
-
- **按钮和图片在同一条消息里**:不再单独发一条按钮消息,而是直接拼在图片下面一起发
|
|
181
|
-
(详情卡片、评论区卡片都是 `[...图片, ...按钮]` 一次 reply;
|
|
182
|
-
评论长图要走切片,切片发送加了「附加元素」参数,按钮只挂在**最后一组**切片后面)。
|
|
183
|
-
- **按钮不在画质面板里**:点清晰度后面板会被撤回(`replyReplacing`),挂在面板上的按钮
|
|
184
|
-
跟着一起没了,点了没反应。面板只在**内部**把封面记进缓存,不显示按钮。
|
|
185
|
-
- **改成 QQ 原生回调按钮**(`keyboard`,`action.type = 1`),不再是 markdown 里的
|
|
186
|
-
`<qqbot-cmd-input>` —— 那个在 QQ 上渲染成**蓝色文字链**,不是按钮。
|
|
187
|
-
原生按钮点一下直接把指令回调给机器人(走 `interaction/button` 事件,插件里已有监听),
|
|
188
|
-
**不用再往输入框塞文本、不用再点一次发送**。
|
|
189
|
-
适配器不支持原生按钮时(官方 `@koishijs/plugin-adapter-qq` 没实现 keyboard)自动退回原来的文字链。
|
|
190
|
-
- **两张卡各带各的按钮**,不再堆在最后一条下面:
|
|
191
|
-
封面 / 详情卡片 → 「提取封面图」;评论区 → 「提取评论区图片」。
|
|
192
|
-
动态卡片同样按内容给(有封面才给「提取封面图」),动态评论区给「提取评论区图片」。
|
|
193
|
-
- **面板那张封面卡上也带「提取封面图」**:发链接后第一条是面板,封面是**单独一条消息**
|
|
194
|
-
发的、选清晰度时只撤下面那张表格、卡片留在群里,所以按钮挂在这里点得到
|
|
195
|
-
(挂在画质表格上不行 —— 一选清晰度整条就被撤了)。面板上**不给**「提取评论区图片」,
|
|
196
|
-
那会儿评论图还没渲染。
|
|
197
|
-
- **修:点「提取评论区图片」却变成重新解析一遍视频**。
|
|
198
|
-
按钮回调的文本是 `kkk评论 bilibili:BV1JSan6GEFW`,而 B站链接指令的正则是靠 BV 号认链接的
|
|
199
|
-
—— **它也能匹配这串字**。`runTextCommand` 按注册顺序取第一条命中,而各平台链接指令
|
|
200
|
-
的优先级是 800、提取指令原来没设(0),于是被抢走、当成「解析这条链接」跑了一遍。
|
|
201
|
-
现在提取指令优先级提到 **900**(正则锚在 `^#?`,抢在最前面也不会误伤别的消息)。
|
|
202
|
-
- **「提取评论区图片」改发评论里用户贴的图**。
|
|
203
|
-
之前发的是插件**渲染出来的那张评论长图**,而那张卡片本身已经发过了 —— 等于原样重发。
|
|
204
|
-
现在缓存里分开记两类图:渲染卡片(`comment`)和评论里用户自己贴的图(`commentPics`,
|
|
205
|
-
取自 `bilibiliComments` 的 `image_urls`),按钮优先发后者,
|
|
206
|
-
点击时过一遍 `processImageUrls` 落地成能发的地址;一条评论都没贴图时才退回渲染卡片。
|
|
207
|
-
视频评论 / 动态评论两处都记了,与「评论区图片收集」那个开关无关(关着也能提取)。
|
|
208
|
-
|
|
209
|
-
### QQ 适配器设置:图片超过指定高度就自动切片
|
|
210
|
-
|
|
211
|
-
- 新增 **`sliceImageAutoHeight`**(「图片发送」组,默认 **6000** 像素,填 `0` = 关)。
|
|
212
|
-
- 原来的「超大图片自动切片」是**先整张发一次、被 QQ 拒收了才补切**:QQ 收得下的超高图
|
|
213
|
-
就这么整张出去了。而太高的图 QQ 会整体缩小显示,卡片下面挂了提取按钮时,
|
|
214
|
-
缩图留下的空白会被按钮撑开 —— 一大片空白很难看。
|
|
215
|
-
- 设了阈值就**直接切开**不再试发,每片都在正常尺寸内,没有这块空白。
|
|
216
|
-
|
|
217
|
-
- **按钮带作品参数**:指令形如 `kkk封面 bilibili:BV1JSan6GEFW`。
|
|
218
|
-
之前按「会话最近一次解析」取图,群里**又有人发了别的链接**之后缓存就被覆盖,
|
|
219
|
-
回头点老卡片下面的按钮会发出来**另一个作品**的图。现在缓存按「平台 + 作品 id」存,
|
|
220
|
-
按钮把键当参数带上,点哪张卡就取哪个作品(手敲不带参数时才回退到本会话最近一条)。
|
|
221
|
-
- **动态也有这两个按钮**:B站动态卡片(图文 / 纯文 / 转发 / 视频 / 专栏)和动态评论区
|
|
222
|
-
下面都挂了;动态的作品键是**里面的 BV**(视频动态)或动态 id,
|
|
223
|
-
封面取视频封面 / 第一张图 / 被转发的那条;没有封面的(纯文字)只给「提取评论区图片」。
|
|
224
|
-
- **只有 QQ / QQ 频道有按钮**;其它平台显示一句提示,照着「引用这条消息发送 `kkk封面 …` /
|
|
225
|
-
`kkk评论 …`」操作即可。
|
|
226
|
-
|
|
227
|
-
### 小红书 / 快手也补上了这两个提取按钮
|
|
228
|
-
|
|
229
|
-
- **小红书**:上一版只做了 B站 / 抖音,小红书整条链路一次都没调用过按钮,
|
|
230
|
-
所以发小红书链接时卡片下面什么都没有。现在笔记信息卡(封面 + 字体正文 + 数据的详情卡)
|
|
231
|
-
下面挂「提取封面图」,评论卡片下面挂「提取评论区图片」。
|
|
232
|
-
封面取 `image_list[0]`,视频笔记的 `image_list` 常为空数组,按
|
|
233
|
-
`video.image.first_frame` → `video.cover` 兜底(和详情卡片显示的是同一个值)。
|
|
234
|
-
- **快手**:这条链路**只有评论区一张卡** —— 作品信息本身就画在评论卡顶部,没有单独的封面卡,
|
|
235
|
-
所以封面与评论区**两个按钮都挂在评论卡下面**。封面取 `photo.coverUrls[0].url`。
|
|
236
|
-
- **「提取评论区图片」发的是评论里用户贴的图**,两个平台各自的取法:
|
|
237
|
-
小红书取每条评论的 `pictures`(根评论 + 子评论都收,按出现顺序去重 ——
|
|
238
|
-
卡片里每条只渲染了第一张,按钮要发的是全部);
|
|
239
|
-
快手取 `rootComments[i].attachments[].content.smallUrl`(多 CDN 取第一条)。
|
|
240
|
-
- 两处都按「平台 + 作品 id」记缓存,按钮回调带着 `xiaohongshu:<note_id>` / `kuaishou:<photoId>`,
|
|
241
|
-
和 B站 / 抖音同一套口径(15 分钟过期、过期提示重发链接)。
|
|
242
|
-
|
|
243
|
-
### 推送消息下面的「解析」按钮
|
|
244
|
-
|
|
245
|
-
- 定时推送出来的动态 / 作品卡片自带链接,但没开自动解析时没人会去解析它。
|
|
246
|
-
现在卡片下面多一个「解析」按钮,点一下即按这条链接解析。
|
|
247
|
-
- 以前这里用的是旧式回调按钮 `segment.button`,且只认 `adapter.name === 'QQ Official Bot'`,
|
|
248
|
-
很多部署上根本不出按钮。现在和「提取图」按钮同一套写法:QQ / QQ 频道给 markdown 按钮,
|
|
249
|
-
其它平台给文字提示。B站、抖音推送都改了。
|
|
250
|
-
- **解析的是动态里面的作品,不是动态本身**:推送卡片已经把这条动态整个渲染出来了,
|
|
251
|
-
再点「解析」去解析同一条动态等于把刚才那张卡片原样再发一遍,没有意义。现在按钮取的是
|
|
252
|
-
**动态里带的那个作品** —— 视频动态取里面的 BV;转发动态取**被转发的**那一条(转发本身只是个壳);
|
|
253
|
-
图文 / 纯文字动态翻正文富文本和网页卡片里的跳转链接,取第一条**能解析的作品链接**
|
|
254
|
-
(话题页、`t.bilibili.com` 动态页、个人主页、专栏、直播间这类会被跳过)。
|
|
255
|
-
一条动态里什么作品都没带的(纯文字、纯图文、转发了条纯文字)→ **不显示按钮**。
|
|
256
|
-
- 按钮前面加了换行:原来按钮元素是拼在卡片内容末尾的,不加换行会粘在图片那一行后面。
|
|
257
|
-
- **只有 QQ / QQ 频道有按钮**;其它平台显示一句提示,照着「引用这条消息发送 `kkk封面` /
|
|
258
|
-
`kkk评论`」操作即可。两个平台都能用的指令:`kkk封面`、`kkk评论`。
|
|
259
|
-
- 点按钮**不重新解析、不重新渲染**:图源取自上次解析(按会话缓存 15 分钟,
|
|
260
|
-
换了作品就整条作废;评论长图是临时文件,会被定时清理,过期就提示先发条链接)。
|
|
3
|
+
## 3.12.0
|
|
4
|
+
|
|
5
|
+
### 新:提取图片的按钮只能点一次
|
|
6
|
+
|
|
7
|
+
卡片下面「提取封面图 / 提取评论区图片」这两个按钮以前可以一直点。有人一直点,
|
|
8
|
+
群里就被同一张图刷屏,而且看不出是谁在点。
|
|
9
|
+
|
|
10
|
+
现在**同一张卡片上的同一个按钮只放行一次**:
|
|
11
|
+
|
|
12
|
+
- 再点不会往群里发任何东西(不再刷屏),但日志里会记下「这次是谁点的、之前是谁点过」,
|
|
13
|
+
真被人刷了能查出来;
|
|
14
|
+
- 封面和评论区是两个按钮,各点各的,互不影响;换一个作品也照常能点;
|
|
15
|
+
- 想再要一次,**重新发一遍链接**就行,新卡片下面的按钮又是可点的(不会被永久锁死);
|
|
16
|
+
- 自己手敲 `kkk封面` / `kkk评论` 不受这个限制,随时都能用。
|
|
17
|
+
|
|
18
|
+
还有一处顺手修的:万一这次没发出去(图过期了、或者发送报错),这次点击**不算数**,
|
|
19
|
+
再点一次还是能发 —— 以前那种「点一次就永久作废」的情况不会出现。
|
|
20
|
+
|
|
21
|
+
## 3.11.1
|
|
22
|
+
|
|
23
|
+
这一版修的主要是两类事:**东西发不出去**,和**按钮点了没反应**。
|
|
24
|
+
|
|
25
|
+
### 修:视频发不出来(快手的长标题)
|
|
26
|
+
|
|
27
|
+
快手有些作品的标题是好几行的长文案。QQ 收群文件时会先看文件名,
|
|
28
|
+
标题里带换行就直接把整个上传退回来了 —— 视频明明已经下好,
|
|
29
|
+
却一个都没发出去,只在群里留一张报错卡(同一个视频连着失败了三次)。
|
|
30
|
+
|
|
31
|
+
现在发之前会把名字收拾干净(去掉换行和非法字符、太长就截断);
|
|
32
|
+
万一还是被 QQ 退回,会自动换个临时名字再发一次。
|
|
33
|
+
|
|
34
|
+
顺带把报错信息也修好了:以前只写一句「失败」,现在会带上 QQ 给的原因,
|
|
35
|
+
真出问题一眼就能看出是什么。
|
|
36
|
+
|
|
37
|
+
### 新:卡片下面可以单独提取封面图 / 评论区的图
|
|
38
|
+
|
|
39
|
+
解析出来的卡片是一整条长图,想单独存封面、或者只看评论里别人贴的图,以前只能自己截图。
|
|
40
|
+
现在卡片下面多了一行按钮:
|
|
41
|
+
|
|
42
|
+
- **提取封面图** —— 把封面单独发一遍;
|
|
43
|
+
- **提取评论区图片** —— 发的是**评论里用户自己贴的图**(不是插件渲染的那张评论长图)。
|
|
44
|
+
|
|
45
|
+
B站、抖音、小红书、快手都做了。只有 QQ / QQ 频道会有按钮,
|
|
46
|
+
其它平台会提示你引用那条消息、手敲 `kkk封面` / `kkk评论`。
|
|
47
|
+
|
|
48
|
+
### 修:「是否收集评论区的图片」打开没反应
|
|
49
|
+
|
|
50
|
+
打开这个开关,解析完就会**直接把评论区里的图发一条**,卡片下面不再重复挂按钮;
|
|
51
|
+
关着(默认)保持原样,想单独存图自己点一下。
|
|
52
|
+
|
|
53
|
+
另外,评论区一条图都没有的时候,那个按钮不会再显示了 —— 反正点了也没东西可发。
|
|
54
|
+
|
|
55
|
+
### 改:指令在控制台里收进「kkk」分组
|
|
56
|
+
|
|
57
|
+
控制台的指令页面以前有 40 条指令铺一地,现在统一收在 `kkk` 下面。
|
|
58
|
+
**几种写法照旧都能用**:`解析 <链接>`、`/解析 <链接>`、`kkk.解析 <链接>`、`kkk 解析 <链接>`。
|
|
59
|
+
|
|
60
|
+
### 修:图片被强制拉伸
|
|
61
|
+
|
|
62
|
+
QQ 的 markdown 图片必须写死尺寸。以前尺寸读不出来时会随便猜一个,
|
|
63
|
+
比例和原图对不上就被硬拉变形。现在尺寸一律实测,实在量不到就退回普通图片发送
|
|
64
|
+
(会糊一点,但比例是对的、也看得见)。
|
|
65
|
+
|
|
66
|
+
### 其它
|
|
67
|
+
|
|
68
|
+
- 图片超过 6000 像素就自动切开再发(这个高度可以在设置里改),不再先整张发一遍、被退回来才补切。
|
|
69
|
+
- 短时间内重复发同一条链接,不会一遍一遍重新出面板了。
|
|
70
|
+
- 推送消息下面多了「解析」按钮,点的是动态里带的那个作品(不是动态本身)。
|
|
71
|
+
- 修:点「提取评论区图片」以前会变成把视频重新解析一遍。
|
|
72
|
+
- 修:日志里的视频预览地址端口是空的、打不开。
|
|
261
73
|
|
|
262
74
|
## 3.10.2
|
|
263
75
|
|
package/lib/build-metadata.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.
|
|
3
|
-
"buildTime": "2026-10-
|
|
4
|
-
"buildTimestamp":
|
|
2
|
+
"version": "3.12.0",
|
|
3
|
+
"buildTime": "2026-10-01T15:04:46.055Z",
|
|
4
|
+
"buildTimestamp": 1790867086061,
|
|
5
5
|
"name": "koishi-plugin-kkk",
|
|
6
6
|
"description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
|
|
7
7
|
"homepage": "https://github.com/maimai993/koishi-plugin-kkk#readme",
|
|
8
|
-
"commitHash": "
|
|
9
|
-
"shortCommitHash": "
|
|
8
|
+
"commitHash": "676659fbbdf78244670d22aee37bb7668ef73b42",
|
|
9
|
+
"shortCommitHash": "676659f"
|
|
10
10
|
}
|
package/lib/karin/apps/tools.js
CHANGED
|
@@ -627,6 +627,20 @@ const handleCardParse = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e, next)
|
|
|
627
627
|
* `卡片消息` 是可推导的中文名,放最前面。
|
|
628
628
|
*/
|
|
629
629
|
exports.cardAPP = node_karin_1.default.command(/卡片消息/, handleCardParse, { name: 'kkk-卡片解析' });
|
|
630
|
+
/**
|
|
631
|
+
* 这条指令是不是**QQ 回调按钮**点出来的。
|
|
632
|
+
*
|
|
633
|
+
* 判据是会话类型:回调按钮(`action.type = 1`)在适配器里被标成
|
|
634
|
+
* `session.type = 'interaction/button'`(见 `koishi-plugin-adapter-qq-crack` 的 `utils.ts`),
|
|
635
|
+
* 而手敲指令、以及不支持原生按钮时给的蓝字文字链(`<qqbot-cmd-input>`),
|
|
636
|
+
* 都会变成**普通消息**(`session.type = 'message'`)。
|
|
637
|
+
*
|
|
638
|
+
* 这个区别正是「限一次」的适用范围:**只有按钮才限一次**。
|
|
639
|
+
* 按钮用户看不见指令、也没法确认自己点没点过,所以由插件兜着;
|
|
640
|
+
* 手敲指令是用户主动发的,随时都能用,留一条明确的路(图发不出来时还能重试)。
|
|
641
|
+
* @param e 消息事件
|
|
642
|
+
*/
|
|
643
|
+
const isButtonClick = (e) => String(e?.session?.type ?? '') === 'interaction/button';
|
|
630
644
|
/**
|
|
631
645
|
* 单独把卡片图发一遍(`kkk封面` / `kkk评论`)。
|
|
632
646
|
*
|
|
@@ -636,6 +650,11 @@ exports.cardAPP = node_karin_1.default.command(/卡片消息/, handleCardParse,
|
|
|
636
650
|
*
|
|
637
651
|
* **不重新解析、不重新渲染**:图源是上次解析记下来的(见 CardImageCache),
|
|
638
652
|
* 15 分钟过期(评论图是临时文件,会被定时清理),过期了就提示先发条链接。
|
|
653
|
+
*
|
|
654
|
+
* **按钮只放行一次**(用户要求:有人一直点,群里被同一张图刷屏、还查不出是谁):
|
|
655
|
+
* `interaction/button` 这条路上按「作品 + 按钮种类」先占后发,重复点击**静默忽略**、
|
|
656
|
+
* 只在日志里记下是谁在点。手敲指令不受此限;重新发一次链接(新卡片)
|
|
657
|
+
* 会 `rememberCardImages` 重新武装按钮。
|
|
639
658
|
*/
|
|
640
659
|
const handleExtractCard = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e, next) => {
|
|
641
660
|
const msg = String(e.msg ?? '');
|
|
@@ -648,7 +667,20 @@ const handleExtractCard = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e, nex
|
|
|
648
667
|
*/
|
|
649
668
|
const arg = msg.replace(/^#?(?:kkk封面|kkk评论|提取封面图|提取评论区图片)/, '').trim();
|
|
650
669
|
const wantComment = /评论/.test(msg);
|
|
651
|
-
const
|
|
670
|
+
const cardKey = (0, CardImageCache_1.resolveCardImageKey)(e, arg);
|
|
671
|
+
const kind = wantComment ? 'comment' : 'cover';
|
|
672
|
+
const label = wantComment ? '提取评论区图片' : '提取封面图';
|
|
673
|
+
/**
|
|
674
|
+
* **拦截重复点击。**「先占后发」而不是「发成功再记」:
|
|
675
|
+
* 反过来写的话,快速连点会有好几次同时通过检查,照样能刷出好几条。
|
|
676
|
+
*/
|
|
677
|
+
const fromButton = isButtonClick(e);
|
|
678
|
+
if (fromButton && !(0, CardImageCache_1.claimCardImageExtract)(cardKey, kind, String(e.userId ?? ''))) {
|
|
679
|
+
const prev = (0, CardImageCache_1.recallCardImageExtract)(cardKey, kind);
|
|
680
|
+
node_karin_1.logger.mark('[提取卡片图] 忽略一次重复点击:%s 点的「%s」,这张卡片上 %s 已经点过了(同一张卡片只放行一次,想再要一次就重新发链接)', String(e.userId ?? '(未知用户)'), label, prev?.by ? String(prev.by) : '(未知用户)');
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const cached = (0, CardImageCache_1.recallCardImages)(cardKey);
|
|
652
684
|
let urls;
|
|
653
685
|
if (wantComment) {
|
|
654
686
|
/**
|
|
@@ -676,14 +708,25 @@ const handleExtractCard = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e, nex
|
|
|
676
708
|
urls = cached?.cover ? [cached.cover] : undefined;
|
|
677
709
|
}
|
|
678
710
|
if (!urls?.length) {
|
|
711
|
+
/** 这次没发出去,不该算「点过了」——否则图一过期按钮就彻底废了 */
|
|
712
|
+
if (fromButton)
|
|
713
|
+
(0, CardImageCache_1.releaseCardImageExtract)(cardKey, kind);
|
|
679
714
|
await e.reply(wantComment
|
|
680
715
|
? '没拿到这条内容的评论区图片 —— 评论里没有用户贴图,或者已经过期(15 分钟)/临时文件被清理了,重新发一下链接再点 ~'
|
|
681
716
|
: '没找到这个作品的封面 —— 可能已经过期(15 分钟),重新发一下链接再点 ~');
|
|
682
717
|
return;
|
|
683
718
|
}
|
|
684
719
|
/** 图片统一走 markdown:QQ 上不会被二次压缩,小字才看得清(见 compat/imageMarkdown) */
|
|
685
|
-
|
|
686
|
-
|
|
720
|
+
try {
|
|
721
|
+
const md = await (0, QqPanel_1.buildMarkdownImageMessage)(urls, 420, (0, ImageSlice_1.platformOf)(e));
|
|
722
|
+
await e.reply(md ?? urls.map((url) => node_karin_1.segment.image(url)));
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
/** 发送失败同样不当「点过了」:用户再点一次还能拿到图 */
|
|
726
|
+
if (fromButton)
|
|
727
|
+
(0, CardImageCache_1.releaseCardImageExtract)(cardKey, kind);
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
687
730
|
node_karin_1.logger.debug('[提取卡片图] 已发送 %s 张(%s)', urls.length, wantComment ? '评论区' : '封面');
|
|
688
731
|
}, { businessName: '提取卡片图' });
|
|
689
732
|
/**
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.cardImageKeyOf = cardImageKeyOf;
|
|
4
4
|
exports.rememberCardImages = rememberCardImages;
|
|
5
|
+
exports.claimCardImageExtract = claimCardImageExtract;
|
|
6
|
+
exports.releaseCardImageExtract = releaseCardImageExtract;
|
|
7
|
+
exports.recallCardImageExtract = recallCardImageExtract;
|
|
5
8
|
exports.rememberLastCardKey = rememberLastCardKey;
|
|
6
9
|
exports.resolveCardImageKey = resolveCardImageKey;
|
|
7
10
|
exports.recallCardImages = recallCardImages;
|
|
@@ -12,6 +15,27 @@ const TTL_MS = 15 * 60 * 1000;
|
|
|
12
15
|
const store = new Map();
|
|
13
16
|
/** 会话 → 最近一次解析的作品键(不带参数手敲指令时的兜底) */
|
|
14
17
|
const lastBySession = new Map();
|
|
18
|
+
/**
|
|
19
|
+
* 回调按钮的「已经点过了」记录:`${作品键}|${种类}` → 谁点的、什么时候。
|
|
20
|
+
*
|
|
21
|
+
* ## 为什么需要它(用户要求)
|
|
22
|
+
*
|
|
23
|
+
* 「提取封面图 / 提取评论区图片」是 **QQ 原生回调按钮**:点一下就把载荷回调给机器人,
|
|
24
|
+
* QQ 那边不会记住「已经点过」,**按钮一直可以点**。于是有人会一直点,
|
|
25
|
+
* 群里就被同一张图刷屏(用户原话:「最多只能触发一次,不然点一直点,不知道谁在刷屏」)。
|
|
26
|
+
*
|
|
27
|
+
* 所以这里记一笔:**同一张卡片上的同一个按钮只放行一次**,后面的点击静默忽略、只记日志。
|
|
28
|
+
* - 作用域是「作品 + 种类」,不是「人」:封面和评论区是两个按钮,互不影响;
|
|
29
|
+
* - **重新解析会重新武装**:新卡片发出去时会 {@link rememberCardImages}(顺便清掉旧记录),
|
|
30
|
+
* 所以「想再要一次」只要重发链接,不会被永久锁死;
|
|
31
|
+
* - 手敲指令(`kkk封面 bilibili:BV…`)**不走这里**,随时都能用,留一条明确的路;
|
|
32
|
+
* - 跟着 {@link TTL_MS} 一起过期:图都过期了,记录留着也没意义。
|
|
33
|
+
*/
|
|
34
|
+
const extracted = new Map();
|
|
35
|
+
/** 记录键 */
|
|
36
|
+
function extractedKeyOf(key, kind) {
|
|
37
|
+
return String(key ?? '') + '|' + kind;
|
|
38
|
+
}
|
|
15
39
|
/** 会话键 */
|
|
16
40
|
function sessionKeyOf(e) {
|
|
17
41
|
return String(e?.contact?.peer ?? e?.channelId ?? '');
|
|
@@ -34,12 +58,19 @@ function sweep() {
|
|
|
34
58
|
if (now - item.at > TTL_MS)
|
|
35
59
|
store.delete(key);
|
|
36
60
|
}
|
|
61
|
+
for (const [key, item] of extracted) {
|
|
62
|
+
if (now - item.at > TTL_MS)
|
|
63
|
+
extracted.delete(key);
|
|
64
|
+
}
|
|
37
65
|
}
|
|
38
66
|
/**
|
|
39
67
|
* 记下本次解析的图。
|
|
40
68
|
*
|
|
41
69
|
* **只补传进来的那几项**:详情页发完先记封面,评论渲染完再补评论区,
|
|
42
70
|
* 后一次调用不会把前一次记的封面抹掉。
|
|
71
|
+
*
|
|
72
|
+
* 顺带**重新武装**这次记的那几种图对应的按钮:新卡片要发出去了,
|
|
73
|
+
* 它下面的按钮应该可以再点一次(旧卡片上的那次点击不作数)。
|
|
43
74
|
* @param key {@link cardImageKeyOf}
|
|
44
75
|
* @param patch 要记录的内容
|
|
45
76
|
*/
|
|
@@ -50,14 +81,56 @@ function rememberCardImages(key, patch) {
|
|
|
50
81
|
const prev = store.get(key);
|
|
51
82
|
const next = prev ?? { key, at: Date.now() };
|
|
52
83
|
next.at = Date.now();
|
|
53
|
-
if (patch.cover)
|
|
84
|
+
if (patch.cover) {
|
|
54
85
|
next.cover = patch.cover;
|
|
55
|
-
|
|
86
|
+
extracted.delete(extractedKeyOf(key, 'cover'));
|
|
87
|
+
}
|
|
88
|
+
if (patch.comment?.length) {
|
|
56
89
|
next.comment = patch.comment;
|
|
57
|
-
|
|
90
|
+
extracted.delete(extractedKeyOf(key, 'comment'));
|
|
91
|
+
}
|
|
92
|
+
if (patch.commentPics?.length) {
|
|
58
93
|
next.commentPics = patch.commentPics;
|
|
94
|
+
extracted.delete(extractedKeyOf(key, 'comment'));
|
|
95
|
+
}
|
|
59
96
|
store.set(key, next);
|
|
60
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* 占一次「卡片按钮已经点过了」。**只有 QQ 回调按钮走这里**(手敲指令不调)。
|
|
100
|
+
*
|
|
101
|
+
* 先占后发、发失败再 {@link releaseCardImageExtract}:
|
|
102
|
+
* 反过来「先发成功再记」的话,快速连点会有几次同时通过检查,照样能刷出好几条。
|
|
103
|
+
* @param key 作品键({@link cardImageKeyOf})
|
|
104
|
+
* @param kind 封面还是评论区
|
|
105
|
+
* @param by 点击者(用于日志里认出「谁在刷屏」)
|
|
106
|
+
* @returns 第一次点返回 `true`(放行);之前已经点过返回 `false`
|
|
107
|
+
*/
|
|
108
|
+
function claimCardImageExtract(key, kind, by) {
|
|
109
|
+
if (!key)
|
|
110
|
+
return true;
|
|
111
|
+
sweep();
|
|
112
|
+
const id = extractedKeyOf(key, kind);
|
|
113
|
+
if (extracted.has(id))
|
|
114
|
+
return false;
|
|
115
|
+
extracted.set(id, { at: Date.now(), by: String(by ?? '') });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
/** 撤销一次占用(这次没发出去 —— 过期、没图、发送报错 —— 不该算「点过了」) */
|
|
119
|
+
function releaseCardImageExtract(key, kind) {
|
|
120
|
+
if (!key)
|
|
121
|
+
return;
|
|
122
|
+
extracted.delete(extractedKeyOf(key, kind));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 这个按钮是不是已经点过了(日志用:带上是谁点的)。
|
|
126
|
+
* @param key 作品键
|
|
127
|
+
* @param kind 封面还是评论区
|
|
128
|
+
*/
|
|
129
|
+
function recallCardImageExtract(key, kind) {
|
|
130
|
+
if (!key)
|
|
131
|
+
return undefined;
|
|
132
|
+
return extracted.get(extractedKeyOf(key, kind));
|
|
133
|
+
}
|
|
61
134
|
/**
|
|
62
135
|
* 记下「这个会话最近解析的是哪个作品」。
|
|
63
136
|
* 用户手敲 `kkk封面`(不带参数)时用它兜底 —— 按钮自己是带参数的,不走这条路。
|
package/package.json
CHANGED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 探针:「提取封面图 / 提取评论区图片」这两个**回调按钮最多只触发一次**。
|
|
3
|
+
*
|
|
4
|
+
* 用户原话:「提取图片的回调按钮最多只能触发一次,不然点一直点,不知道谁在刷屏」。
|
|
5
|
+
*
|
|
6
|
+
* QQ 的原生回调按钮(`action.type = 1`)是**发出去就收不回**的东西:QQ 那边不会记住
|
|
7
|
+
* 「这个按钮点过了」,所以只能由插件自己拦 —— 这里就把拦的那段(真 `handleExtractCard`)
|
|
8
|
+
* 跑起来,逐条验证:
|
|
9
|
+
* ① 第一次点:正常出图;
|
|
10
|
+
* ② 再点(同一个人 / 换个人):**静默忽略**,群里不再多一条,日志里记下是谁在点;
|
|
11
|
+
* ③ 封面按钮和评论按钮是两件事,互不影响;
|
|
12
|
+
* ④ 换作品互不影响;
|
|
13
|
+
* ⑤ **重新解析会重新武装**(新卡片发出来,按钮又能点一次)—— 不然就永久锁死了;
|
|
14
|
+
* ⑥ **手敲指令不受限**(留一条明确的路,图发不出来时还能重试);
|
|
15
|
+
* ⑦ 这次没发出去(图过期 / 发送失败)**不算「点过了」**,再点还能成;
|
|
16
|
+
* ⑧ 记录跟着缓存一起 15 分钟过期。
|
|
17
|
+
*
|
|
18
|
+
* 走的是**真产物**(`lib/karin/apps/tools.js` 里 `extractCardAPP` 的 handler),
|
|
19
|
+
* 只把「量图片尺寸」「把图片地址落地」这两步打桩 —— 那是网络活,与本次要验的逻辑无关。
|
|
20
|
+
* 不联网、不连 QQ、不碰用户配置、不写任何文件(dataRoot 用临时目录)。
|
|
21
|
+
*
|
|
22
|
+
* 用法:node scripts/probe-extract-once.cjs
|
|
23
|
+
*/
|
|
24
|
+
const fs = require('node:fs')
|
|
25
|
+
const os = require('node:os')
|
|
26
|
+
const path = require('node:path')
|
|
27
|
+
|
|
28
|
+
const lib = path.join(__dirname, '..', 'lib')
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* **先把运行环境打桩,再 require 业务模块。**
|
|
32
|
+
*
|
|
33
|
+
* `Config` / `runtime` / `logger` 都是在业务模块 **import 期**就被读到的
|
|
34
|
+
* (比如 `tools.ts` 里 `priority: Config.app.videoTool ? …` 就是模块级表达式),
|
|
35
|
+
* 所以顺序不能反,否则整个 require 直接抛。
|
|
36
|
+
*/
|
|
37
|
+
const runtime = require(path.join(lib, 'compat/runtime.js'))
|
|
38
|
+
runtime.bindRuntime({
|
|
39
|
+
ctx: { config: { port: 5200 } },
|
|
40
|
+
/**
|
|
41
|
+
* `errorNoCard: true` —— 出错只发文字、不渲染错误卡片。
|
|
42
|
+
* 第 7b 段会故意让发送失败,那时异常会流进错误处理器;不关掉卡片渲染的话
|
|
43
|
+
* 探针要凭空渲染一张几 MB 的大图(还要切片、发到几个账号),与本次要验的东西无关。
|
|
44
|
+
*/
|
|
45
|
+
config: { app: {}, errorNoCard: true },
|
|
46
|
+
dataRoot: fs.mkdtempSync(path.join(os.tmpdir(), 'kkk-probe-extract-once-'))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/** 接住日志:要断言「谁在刷屏」真的被记下来了 */
|
|
50
|
+
const marks = []
|
|
51
|
+
const warns = []
|
|
52
|
+
require(path.join(lib, 'compat/logger.js')).setLogger({
|
|
53
|
+
debug () {},
|
|
54
|
+
info: (message) => marks.push(String(message)),
|
|
55
|
+
warn: (message) => warns.push(String(message)),
|
|
56
|
+
error () {}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const Config = require(path.join(lib, 'karin/module/utils/Config.js'))
|
|
60
|
+
Config.Config = {
|
|
61
|
+
/** EmojiReply 关掉:不然每次调用都会去尝试给消息加表情(探针里没有真 bot) */
|
|
62
|
+
app: { EmojiReply: false, videoTool: false, videoSendMode: 'url' },
|
|
63
|
+
amagi: { cookies: {} },
|
|
64
|
+
bilibili: {},
|
|
65
|
+
douyin: {},
|
|
66
|
+
kuaishou: {},
|
|
67
|
+
xiaohongshu: {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ImageHelper = require(path.join(lib, 'karin/module/utils/ImageHelper.js'))
|
|
71
|
+
const QqPanel = require(path.join(lib, 'karin/module/utils/QqPanel.js'))
|
|
72
|
+
const CardImageCache = require(path.join(lib, 'karin/module/utils/CardImageCache.js'))
|
|
73
|
+
const tools = require(path.join(lib, 'karin/apps/tools.js'))
|
|
74
|
+
|
|
75
|
+
/** 量尺寸那一步(会去下载图片):换成固定串,断言里只关心「发了几次」 */
|
|
76
|
+
QqPanel.buildMarkdownImageMessage = async (urls) =>
|
|
77
|
+
urls?.length ? { type: 'markdown', attrs: { content: urls.map((url) => '').join('\n') } } : undefined
|
|
78
|
+
/** 把图片地址落地那一步(会去下载 / 转 base64):原样返回 */
|
|
79
|
+
ImageHelper.processImageUrls = async (urls) => urls
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 错误处理器也打桩。
|
|
83
|
+
*
|
|
84
|
+
* 第 7b 段要验「发送失败时不该占用额度」,那就必须真的让 `e.reply` 抛一次 ——
|
|
85
|
+
* 异常会流进 `wrapWithErrorHandler` 的错误分支。真实的错误处理会上报(联网)、
|
|
86
|
+
* 给主人 / 管理员发通知,探针里既没网也没账号,全打掉即可。
|
|
87
|
+
* (上面 `errorNoCard` 已经省掉了渲染那一步。)
|
|
88
|
+
*/
|
|
89
|
+
const errorReport = require(path.join(lib, 'karin/module/utils/ErrorReport.js'))
|
|
90
|
+
errorReport.uploadErrorReport = async () => null
|
|
91
|
+
const errorSender = require(path.join(lib, 'karin/module/utils/ErrorHandler/sender.js'))
|
|
92
|
+
for (const name of ['sendErrorToTrigger', 'sendErrorToMaster', 'sendErrorToAllMasters', 'sendErrorToAdmins', 'sendErrorToConfiguredIds']) {
|
|
93
|
+
errorSender[name] = async () => {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let failed = 0
|
|
97
|
+
const check = (name, ok, detail) => {
|
|
98
|
+
console.log((ok ? ' ✔ ' : ' ✘ ') + name + (detail ? ' → ' + detail : ''))
|
|
99
|
+
if (!ok) failed++
|
|
100
|
+
}
|
|
101
|
+
/** 章节标题 */
|
|
102
|
+
const section = (title) => console.log('\n=== ' + title + ' ===')
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 造一条假消息。
|
|
106
|
+
* @param opts.msg 指令原文(按钮回调的 data 拼上 `#` 后就是它)
|
|
107
|
+
* @param opts.button 是不是 QQ 回调按钮点出来的(决定 `session.type`)
|
|
108
|
+
* @param opts.userId 谁点的
|
|
109
|
+
* @param opts.peer 会话
|
|
110
|
+
*/
|
|
111
|
+
const makeMessage = (opts) => {
|
|
112
|
+
const replies = []
|
|
113
|
+
const e = {
|
|
114
|
+
msg: opts.msg,
|
|
115
|
+
userId: opts.userId ?? 'user-1',
|
|
116
|
+
selfId: 'self-1',
|
|
117
|
+
groupId: 'group-1',
|
|
118
|
+
isGroup: true,
|
|
119
|
+
isPrivate: false,
|
|
120
|
+
channelId: opts.peer ?? 'group-1',
|
|
121
|
+
contact: { peer: opts.peer ?? 'group-1', guildId: 'group-1', isGroup: true, userId: opts.userId ?? 'user-1' },
|
|
122
|
+
sender: { userId: opts.userId ?? 'user-1', nick: '触发者' },
|
|
123
|
+
messageId: 'msg-1',
|
|
124
|
+
/** 关键判据:回调按钮 = `interaction/button`,手敲 / 蓝字链 = `message` */
|
|
125
|
+
session: { type: opts.button ? 'interaction/button' : 'message' },
|
|
126
|
+
bot: { adapter: { name: 'adapter-qq-crack' } },
|
|
127
|
+
reply: async (content) => {
|
|
128
|
+
replies.push(content)
|
|
129
|
+
return [{ id: 'r' + replies.length }]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { e, replies }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 发出去的东西是不是「图」(markdown 段里带了图片) */
|
|
136
|
+
const hasImage = (content) => /!\[[^\]]*\]\([^)\s]+\)/.test(JSON.stringify(content ?? ''))
|
|
137
|
+
|
|
138
|
+
/** 跑一次 handler(真产物里那个被 wrapWithErrorHandler 包过的) */
|
|
139
|
+
const click = async (opts) => {
|
|
140
|
+
const { e, replies } = makeMessage(opts)
|
|
141
|
+
await tools.extractCardAPP.handler(e, () => undefined)
|
|
142
|
+
return replies
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const COVER_KEY = CardImageCache.cardImageKeyOf('bilibili', 'BV1JSan6GEFW')
|
|
146
|
+
const COMMENT_KEY = CardImageCache.cardImageKeyOf('douyin', '7123456789')
|
|
147
|
+
const OTHER_KEY = CardImageCache.cardImageKeyOf('kuaishou', 'photo-9')
|
|
148
|
+
|
|
149
|
+
const armCover = (key) => CardImageCache.rememberCardImages(key, { cover: 'https://cdn/' + key + '/cover.jpg' })
|
|
150
|
+
const armComment = (key) => CardImageCache.rememberCardImages(key, { commentPics: ['https://cdn/' + key + '/c1.jpg'] })
|
|
151
|
+
|
|
152
|
+
;(async () => {
|
|
153
|
+
try {
|
|
154
|
+
section('1. 第一次点:正常出图')
|
|
155
|
+
{
|
|
156
|
+
armCover(COVER_KEY)
|
|
157
|
+
const replies = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-A' })
|
|
158
|
+
check('点一次 → 出图一条', replies.length === 1 && hasImage(replies[0]), '发送 ' + replies.length + ' 次')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
section('2. 再点:静默忽略,不再刷屏')
|
|
162
|
+
{
|
|
163
|
+
/** 同一个人连点 */
|
|
164
|
+
const again = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-A' })
|
|
165
|
+
check('同一个人再点 → 一条都不发', again.length === 0, '发送 ' + again.length + ' 次')
|
|
166
|
+
/** 换个人点,同样拦掉(拦的是「这张卡片这个按钮」,不是「这个人」) */
|
|
167
|
+
const other = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-B' })
|
|
168
|
+
check('换个人点 → 也拦掉(群里就是同一张图,谁点都不再发)', other.length === 0, '发送 ' + other.length + ' 次')
|
|
169
|
+
const spam = marks.filter((line) => line.includes('重复点击'))
|
|
170
|
+
/** 前两次点击各产生一条:第一条说「user-A 又点了一次」,第二条说「user-B 也在点,但 user-A 已经点过了」 */
|
|
171
|
+
check(
|
|
172
|
+
'日志里记下了「谁在点」(这次点的 + 第一次点的都要有)',
|
|
173
|
+
spam.length === 2 && spam[1].includes('user-B') && spam[1].includes('user-A'),
|
|
174
|
+
spam[1] ?? '(没有这条日志)'
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
section('3. 封面 / 评论是两个按钮,互不影响')
|
|
179
|
+
{
|
|
180
|
+
armComment(COMMENT_KEY)
|
|
181
|
+
const comment = await click({ msg: '#kkk评论 ' + COMMENT_KEY, button: true, userId: 'user-B' })
|
|
182
|
+
check('评论区按钮照常能用(没被封面那次点击带累)', comment.length === 1 && hasImage(comment[0]), '发送 ' + comment.length + ' 次')
|
|
183
|
+
const commentAgain = await click({ msg: '#kkk评论 ' + COMMENT_KEY, button: true, userId: 'user-C' })
|
|
184
|
+
check('评论按钮自己也只能点一次', commentAgain.length === 0, '发送 ' + commentAgain.length + ' 次')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
section('4. 换作品互不影响')
|
|
188
|
+
{
|
|
189
|
+
armCover(OTHER_KEY)
|
|
190
|
+
const card2 = await click({ msg: '#kkk封面 ' + OTHER_KEY, button: true, userId: 'user-A' })
|
|
191
|
+
check('另一个作品的封面按钮可以点', card2.length === 1 && hasImage(card2[0]), '发送 ' + card2.length + ' 次')
|
|
192
|
+
const u1 = await click({ msg: '#kkk评论 ' + COMMENT_KEY, button: true, userId: 'user-A' }) // 评论那条还是 user-B 点过
|
|
193
|
+
check('缓存里两件作品各记各的,不会串', u1.length === 0, '发送 ' + u1.length + ' 次')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
section('5. 重新发一次链接(新卡片)→ 按钮重新可用')
|
|
197
|
+
{
|
|
198
|
+
/** 同一个作品又解析了一遍:`rememberCardImages` 会顺手清掉「点过了」记录 */
|
|
199
|
+
armCover(COVER_KEY)
|
|
200
|
+
const reArmed = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-C' })
|
|
201
|
+
check('新卡片下面的按钮又能点一次(不会被永久锁死)', reArmed.length === 1 && hasImage(reArmed[0]), '发送 ' + reArmed.length + ' 次')
|
|
202
|
+
const third = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-C' })
|
|
203
|
+
check('新卡片上也只放行一次', third.length === 0, '发送 ' + third.length + ' 次')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
section('6. 手敲指令不受限(留一条明确的路)')
|
|
207
|
+
{
|
|
208
|
+
/** 上一步刚被 user-C 点过,这里手敲两次都该出图 */
|
|
209
|
+
/** 先记下水线:只检查「手敲这一段」有没有新写日志(上面按钮那几段的日志不能算进来) */
|
|
210
|
+
const before = marks.length
|
|
211
|
+
const typed1 = await click({ msg: 'kkk封面 ' + COVER_KEY, button: false, userId: 'user-D' })
|
|
212
|
+
const typed2 = await click({ msg: 'kkk封面 ' + COVER_KEY, button: false, userId: 'user-D' })
|
|
213
|
+
check('手敲(不是按钮)→ 两次都出图', typed1.length === 1 && typed2.length === 1 && hasImage(typed2[0]), typed1.length + ' / ' + typed2.length)
|
|
214
|
+
check('手敲不会往「谁在刷屏」日志里写东西', !marks.slice(before).some((line) => line.includes('重复点击')), marks.slice(before).join(' | ') || '(没有新日志)')
|
|
215
|
+
/** 旧 `#` 写法同样是「手敲」口径 */
|
|
216
|
+
const hashTyped = await click({ msg: '#kkk封面 ' + COVER_KEY, button: false, userId: 'user-D' })
|
|
217
|
+
check('旧 `#kkk封面` 写法也照常', hashTyped.length === 1, '发送 ' + hashTyped.length + ' 次')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
section('7. 这次没发出去,不算「点过了」')
|
|
221
|
+
{
|
|
222
|
+
const emptyKey = CardImageCache.cardImageKeyOf('xiaohongshu', 'note-empty')
|
|
223
|
+
/** 缓存里什么都没有:按钮点了只会得到一句提示 */
|
|
224
|
+
const miss = await click({ msg: '#kkk封面 ' + emptyKey, button: true, userId: 'user-E' })
|
|
225
|
+
check('没图 → 回一句提示(不是图)', miss.length === 1 && !hasImage(miss[0]), '发送 ' + miss.length + ' 次')
|
|
226
|
+
/** 关键:这次不该占用掉额度 —— 等图有了再点必须能成 */
|
|
227
|
+
CardImageCache.rememberCardImages(emptyKey, { cover: 'https://cdn/k/cover.jpg' })
|
|
228
|
+
const retry = await click({ msg: '#kkk封面 ' + emptyKey, button: true, userId: 'user-E' })
|
|
229
|
+
check('图补上后再点 → 能出图(失败没占额度)', retry.length === 1 && hasImage(retry[0]), '发送 ' + retry.length + ' 次')
|
|
230
|
+
}
|
|
231
|
+
{
|
|
232
|
+
/** 过期的那条路:作品键在缓存里根本不存在(= 按钮没带参数、也没解析记录) */
|
|
233
|
+
const { e } = makeMessage({ msg: '#kkk封面', button: true, userId: 'user-F' })
|
|
234
|
+
e.contact.peer = 'group-elsewhere'
|
|
235
|
+
e.channelId = 'group-elsewhere'
|
|
236
|
+
const replies = []
|
|
237
|
+
e.reply = async (content) => { replies.push(content); return [{ id: 'x' }] }
|
|
238
|
+
await tools.extractCardAPP.handler(e, () => undefined)
|
|
239
|
+
check('作品键都拿不到 → 也是提示,不抛错', replies.length === 1 && !hasImage(replies[0]), '发送 ' + replies.length + ' 次')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
/**
|
|
244
|
+
* **发送那一步报错时也不该占用额度。**
|
|
245
|
+
*
|
|
246
|
+
* 这条是真跑的:把 `e.reply` 换成抛错,异常会一路走到 `wrapWithErrorHandler`
|
|
247
|
+
* 的错误分支(错误上报 / 发通知已在上面的打桩里换成空实现)。
|
|
248
|
+
* 如果实现里漏了 `release`,下面那次重试就会被自己的「已点过」记录挡住 ——
|
|
249
|
+
* 群里表现成「这张卡片再也发不出图了」。
|
|
250
|
+
*/
|
|
251
|
+
const failKey = CardImageCache.cardImageKeyOf('bilibili', 'BVfail')
|
|
252
|
+
armCover(failKey)
|
|
253
|
+
const { e } = makeMessage({ msg: '#kkk封面 ' + failKey, button: true, userId: 'user-Z' })
|
|
254
|
+
e.reply = async () => { throw new Error('模拟发送失败') }
|
|
255
|
+
let threw = false
|
|
256
|
+
try {
|
|
257
|
+
await tools.extractCardAPP.handler(e, () => undefined)
|
|
258
|
+
} catch {
|
|
259
|
+
threw = true
|
|
260
|
+
}
|
|
261
|
+
console.log(' · 发送抛错后 handler ' + (threw ? '把错误抛了出来(错误处理器没接住)' : '由错误处理器接住并返回'))
|
|
262
|
+
const retry = await click({ msg: '#kkk封面 ' + failKey, button: true, userId: 'user-Z' })
|
|
263
|
+
check('发送失败 → 不算「点过了」,再点一次还能出图', retry.length === 1 && hasImage(retry[0]), '发送 ' + retry.length + ' 次')
|
|
264
|
+
const afterRetry = await click({ msg: '#kkk封面 ' + failKey, button: true, userId: 'user-Z' })
|
|
265
|
+
check('重试成功之后才真正占用(第三次点被拦)', afterRetry.length === 0, '发送 ' + afterRetry.length + ' 次')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
section('8. 记录跟着缓存一起过期(15 分钟)')
|
|
269
|
+
{
|
|
270
|
+
const ttlKey = CardImageCache.cardImageKeyOf('bilibili', 'BVttl')
|
|
271
|
+
armCover(ttlKey)
|
|
272
|
+
check('先占一次 → true', CardImageCache.claimCardImageExtract(ttlKey, 'cover', 'user-G') === true)
|
|
273
|
+
check('紧接着再占 → false(拦住了)', CardImageCache.claimCardImageExtract(ttlKey, 'cover', 'user-G') === false)
|
|
274
|
+
/** 把时间拨过 TTL:记录和缓存一起被 sweep 清掉 */
|
|
275
|
+
const realNow = Date.now
|
|
276
|
+
Date.now = () => realNow() + 16 * 60 * 1000
|
|
277
|
+
try {
|
|
278
|
+
check('过了 15 分钟 → 记录跟着过期,又能占了', CardImageCache.claimCardImageExtract(ttlKey, 'cover', 'user-H') === true)
|
|
279
|
+
check('同一时刻缓存也过期了', CardImageCache.recallCardImages(ttlKey) === undefined)
|
|
280
|
+
} finally {
|
|
281
|
+
Date.now = realNow
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
section('9. release / 隔离的最小口径')
|
|
286
|
+
{
|
|
287
|
+
const k = CardImageCache.cardImageKeyOf('bilibili', 'BVdirect')
|
|
288
|
+
CardImageCache.claimCardImageExtract(k, 'cover', 'u')
|
|
289
|
+
CardImageCache.releaseCardImageExtract(k, 'cover')
|
|
290
|
+
check('release 之后又能占(发送失败重试靠它)', CardImageCache.claimCardImageExtract(k, 'cover', 'u') === true)
|
|
291
|
+
check('release 只撤销自己那一种,不碰另一种', CardImageCache.claimCardImageExtract(k, 'comment', 'u') === true)
|
|
292
|
+
CardImageCache.releaseCardImageExtract(k, 'cover')
|
|
293
|
+
check('撤销封面后,评论那条记录还在', CardImageCache.recallCardImageExtract(k, 'comment')?.by === 'u')
|
|
294
|
+
check('空作品键不参与拦截(拿不到键时不该把所有点击都堵死)', CardImageCache.claimCardImageExtract('', 'cover', 'u') === true)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
section('10. 源码守卫:只在「按钮」那条路上判一次')
|
|
298
|
+
{
|
|
299
|
+
/** 先剥注释再匹配 —— 注释里写到的函数名会把断言带偏(这个坑踩过两次) */
|
|
300
|
+
const stripComments = (source) => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
|
301
|
+
const src = stripComments(fs.readFileSync(path.join(lib, 'karin/apps/tools.js'), 'utf8'))
|
|
302
|
+
check('判据是会话类型 `interaction/button`', /e\?\.session\?\.type[\s\S]{0,40}===\s*'interaction\/button'/.test(src))
|
|
303
|
+
check('只在 fromButton 为真时才占用', /if\s*\(\s*fromButton\s*&&\s*!\s*\(0,\s*CardImageCache_1\.claimCardImageExtract\)/.test(src))
|
|
304
|
+
check('claim 全文只出现一次(别处不许再判一遍)', (src.match(/claimCardImageExtract\)\(/g) ?? []).length === 1, String((src.match(/claimCardImageExtract\)\(/g) ?? []).length))
|
|
305
|
+
check('两条失败路都 release 了(没图 / 发送报错)', (src.match(/releaseCardImageExtract\)\(/g) ?? []).length === 2, String((src.match(/releaseCardImageExtract\)\(/g) ?? []).length))
|
|
306
|
+
const cacheSrc = stripComments(fs.readFileSync(path.join(lib, 'karin/module/utils/CardImageCache.js'), 'utf8'))
|
|
307
|
+
check('重新记图时会清掉旧记录(重新武装)', /extracted\.delete\(/.test(cacheSrc))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log('\n' + (failed ? '✘ 有 ' + failed + ' 项没通过' : '✔ 全部通过' + (warns.length ? '(warn ' + warns.length + ' 条)' : '')))
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error('探针自身出错:', error)
|
|
313
|
+
failed++
|
|
314
|
+
} finally {
|
|
315
|
+
process.exit(failed ? 1 : 0)
|
|
316
|
+
}
|
|
317
|
+
})()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** 离线自验:CHANGELOG 的 `## x.y.z` 标题必须能被 range() 裁出来 */
|
|
2
|
+
const fs = require('node:fs')
|
|
3
|
+
const os = require('node:os')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
|
|
6
|
+
const root = 'E:/devkoishi/plugins/koishi-plugin-kkk'
|
|
7
|
+
const lib = path.join(root, 'lib')
|
|
8
|
+
|
|
9
|
+
const runtime = require(path.join(lib, 'compat/runtime.js'))
|
|
10
|
+
runtime.bindRuntime({ ctx: { config: { port: 5200 } }, config: { app: {} }, dataRoot: os.tmpdir() })
|
|
11
|
+
require(path.join(lib, 'compat/logger.js')).setLogger({ debug () {}, info () {}, warn () {}, error () {} })
|
|
12
|
+
|
|
13
|
+
const karin = require(path.join(lib, 'compat/node-karin.js'))
|
|
14
|
+
const md = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8')
|
|
15
|
+
|
|
16
|
+
console.log('解析出的版本:' + Object.keys(karin.parseChangelog(md)).join(' , '))
|
|
17
|
+
const cases = [['3.11.1', '3.12.0'], ['3.11.0', '3.12.0'], ['3.10.2', '3.12.0'], ['3.12.0', '3.12.0']]
|
|
18
|
+
for (const [startVersion, endVersion] of cases) {
|
|
19
|
+
const out = karin.range({ data: md, startVersion, endVersion, compare: 'semver' })
|
|
20
|
+
const keys = Object.keys(karin.parseChangelog(out))
|
|
21
|
+
const dumped = out.length >= md.length
|
|
22
|
+
console.log(
|
|
23
|
+
'range(' + startVersion + ' → ' + endVersion + '):命中 [' + keys.join(', ') + '] 长度=' + out.length +
|
|
24
|
+
(dumped ? ' ⚠ 返回了整份日志(没裁出来)' : '')
|
|
25
|
+
)
|
|
26
|
+
}
|
package/src/karin/apps/tools.ts
CHANGED
|
@@ -7,7 +7,14 @@ import {
|
|
|
7
7
|
} from '@/module/utils/QqPanel'
|
|
8
8
|
import { replyReplacing } from '@/module/utils/QqPanel'
|
|
9
9
|
import { resolveCardToUrl } from '@/module/utils/CardParser'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
claimCardImageExtract,
|
|
12
|
+
recallCardImageExtract,
|
|
13
|
+
recallCardImages,
|
|
14
|
+
releaseCardImageExtract,
|
|
15
|
+
resolveCardImageKey,
|
|
16
|
+
type CardImageKind
|
|
17
|
+
} from '@/module/utils/CardImageCache'
|
|
11
18
|
import { processImageUrls } from '@/module/utils/ImageHelper'
|
|
12
19
|
|
|
13
20
|
import { Common, downloadVideo } from '@/module'
|
|
@@ -658,6 +665,21 @@ const handleCardParse = wrapWithErrorHandler(
|
|
|
658
665
|
*/
|
|
659
666
|
export const cardAPP = karin.command(/卡片消息/, handleCardParse, { name: 'kkk-卡片解析' })
|
|
660
667
|
|
|
668
|
+
/**
|
|
669
|
+
* 这条指令是不是**QQ 回调按钮**点出来的。
|
|
670
|
+
*
|
|
671
|
+
* 判据是会话类型:回调按钮(`action.type = 1`)在适配器里被标成
|
|
672
|
+
* `session.type = 'interaction/button'`(见 `koishi-plugin-adapter-qq-crack` 的 `utils.ts`),
|
|
673
|
+
* 而手敲指令、以及不支持原生按钮时给的蓝字文字链(`<qqbot-cmd-input>`),
|
|
674
|
+
* 都会变成**普通消息**(`session.type = 'message'`)。
|
|
675
|
+
*
|
|
676
|
+
* 这个区别正是「限一次」的适用范围:**只有按钮才限一次**。
|
|
677
|
+
* 按钮用户看不见指令、也没法确认自己点没点过,所以由插件兜着;
|
|
678
|
+
* 手敲指令是用户主动发的,随时都能用,留一条明确的路(图发不出来时还能重试)。
|
|
679
|
+
* @param e 消息事件
|
|
680
|
+
*/
|
|
681
|
+
const isButtonClick = (e: any): boolean => String(e?.session?.type ?? '') === 'interaction/button'
|
|
682
|
+
|
|
661
683
|
/**
|
|
662
684
|
* 单独把卡片图发一遍(`kkk封面` / `kkk评论`)。
|
|
663
685
|
*
|
|
@@ -667,6 +689,11 @@ export const cardAPP = karin.command(/卡片消息/, handleCardParse, { name: 'k
|
|
|
667
689
|
*
|
|
668
690
|
* **不重新解析、不重新渲染**:图源是上次解析记下来的(见 CardImageCache),
|
|
669
691
|
* 15 分钟过期(评论图是临时文件,会被定时清理),过期了就提示先发条链接。
|
|
692
|
+
*
|
|
693
|
+
* **按钮只放行一次**(用户要求:有人一直点,群里被同一张图刷屏、还查不出是谁):
|
|
694
|
+
* `interaction/button` 这条路上按「作品 + 按钮种类」先占后发,重复点击**静默忽略**、
|
|
695
|
+
* 只在日志里记下是谁在点。手敲指令不受此限;重新发一次链接(新卡片)
|
|
696
|
+
* 会 `rememberCardImages` 重新武装按钮。
|
|
670
697
|
*/
|
|
671
698
|
const handleExtractCard = wrapWithErrorHandler(
|
|
672
699
|
async (e, next) => {
|
|
@@ -680,7 +707,25 @@ const handleExtractCard = wrapWithErrorHandler(
|
|
|
680
707
|
*/
|
|
681
708
|
const arg = msg.replace(/^#?(?:kkk封面|kkk评论|提取封面图|提取评论区图片)/, '').trim()
|
|
682
709
|
const wantComment = /评论/.test(msg)
|
|
683
|
-
const
|
|
710
|
+
const cardKey = resolveCardImageKey(e, arg)
|
|
711
|
+
const kind: CardImageKind = wantComment ? 'comment' : 'cover'
|
|
712
|
+
const label = wantComment ? '提取评论区图片' : '提取封面图'
|
|
713
|
+
/**
|
|
714
|
+
* **拦截重复点击。**「先占后发」而不是「发成功再记」:
|
|
715
|
+
* 反过来写的话,快速连点会有好几次同时通过检查,照样能刷出好几条。
|
|
716
|
+
*/
|
|
717
|
+
const fromButton = isButtonClick(e)
|
|
718
|
+
if (fromButton && !claimCardImageExtract(cardKey, kind, String(e.userId ?? ''))) {
|
|
719
|
+
const prev = recallCardImageExtract(cardKey, kind)
|
|
720
|
+
logger.mark(
|
|
721
|
+
'[提取卡片图] 忽略一次重复点击:%s 点的「%s」,这张卡片上 %s 已经点过了(同一张卡片只放行一次,想再要一次就重新发链接)',
|
|
722
|
+
String(e.userId ?? '(未知用户)'),
|
|
723
|
+
label,
|
|
724
|
+
prev?.by ? String(prev.by) : '(未知用户)'
|
|
725
|
+
)
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
const cached = recallCardImages(cardKey)
|
|
684
729
|
let urls: string[] | undefined
|
|
685
730
|
if (wantComment) {
|
|
686
731
|
/**
|
|
@@ -705,14 +750,22 @@ const handleExtractCard = wrapWithErrorHandler(
|
|
|
705
750
|
urls = cached?.cover ? [cached.cover] : undefined
|
|
706
751
|
}
|
|
707
752
|
if (!urls?.length) {
|
|
753
|
+
/** 这次没发出去,不该算「点过了」——否则图一过期按钮就彻底废了 */
|
|
754
|
+
if (fromButton) releaseCardImageExtract(cardKey, kind)
|
|
708
755
|
await e.reply(wantComment
|
|
709
756
|
? '没拿到这条内容的评论区图片 —— 评论里没有用户贴图,或者已经过期(15 分钟)/临时文件被清理了,重新发一下链接再点 ~'
|
|
710
757
|
: '没找到这个作品的封面 —— 可能已经过期(15 分钟),重新发一下链接再点 ~')
|
|
711
758
|
return
|
|
712
759
|
}
|
|
713
760
|
/** 图片统一走 markdown:QQ 上不会被二次压缩,小字才看得清(见 compat/imageMarkdown) */
|
|
714
|
-
|
|
715
|
-
|
|
761
|
+
try {
|
|
762
|
+
const md = await buildMarkdownImageMessage(urls, 420, platformOf(e))
|
|
763
|
+
await e.reply(md ?? urls.map((url) => segment.image(url)))
|
|
764
|
+
} catch (error) {
|
|
765
|
+
/** 发送失败同样不当「点过了」:用户再点一次还能拿到图 */
|
|
766
|
+
if (fromButton) releaseCardImageExtract(cardKey, kind)
|
|
767
|
+
throw error
|
|
768
|
+
}
|
|
716
769
|
logger.debug('[提取卡片图] 已发送 %s 张(%s)', urls.length, wantComment ? '评论区' : '封面')
|
|
717
770
|
},
|
|
718
771
|
{ businessName: '提取卡片图' }
|
|
@@ -45,11 +45,37 @@ export interface CardImages {
|
|
|
45
45
|
/** 缓存有效期:评论图是临时文件,别放太久 */
|
|
46
46
|
const TTL_MS = 15 * 60 * 1000
|
|
47
47
|
|
|
48
|
+
/** 按钮种类:封面 / 评论区 */
|
|
49
|
+
export type CardImageKind = 'cover' | 'comment'
|
|
50
|
+
|
|
48
51
|
/** 作品 → 图 */
|
|
49
52
|
const store = new Map<string, CardImages>()
|
|
50
53
|
/** 会话 → 最近一次解析的作品键(不带参数手敲指令时的兜底) */
|
|
51
54
|
const lastBySession = new Map<string, string>()
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* 回调按钮的「已经点过了」记录:`${作品键}|${种类}` → 谁点的、什么时候。
|
|
58
|
+
*
|
|
59
|
+
* ## 为什么需要它(用户要求)
|
|
60
|
+
*
|
|
61
|
+
* 「提取封面图 / 提取评论区图片」是 **QQ 原生回调按钮**:点一下就把载荷回调给机器人,
|
|
62
|
+
* QQ 那边不会记住「已经点过」,**按钮一直可以点**。于是有人会一直点,
|
|
63
|
+
* 群里就被同一张图刷屏(用户原话:「最多只能触发一次,不然点一直点,不知道谁在刷屏」)。
|
|
64
|
+
*
|
|
65
|
+
* 所以这里记一笔:**同一张卡片上的同一个按钮只放行一次**,后面的点击静默忽略、只记日志。
|
|
66
|
+
* - 作用域是「作品 + 种类」,不是「人」:封面和评论区是两个按钮,互不影响;
|
|
67
|
+
* - **重新解析会重新武装**:新卡片发出去时会 {@link rememberCardImages}(顺便清掉旧记录),
|
|
68
|
+
* 所以「想再要一次」只要重发链接,不会被永久锁死;
|
|
69
|
+
* - 手敲指令(`kkk封面 bilibili:BV…`)**不走这里**,随时都能用,留一条明确的路;
|
|
70
|
+
* - 跟着 {@link TTL_MS} 一起过期:图都过期了,记录留着也没意义。
|
|
71
|
+
*/
|
|
72
|
+
const extracted = new Map<string, { at: number, by: string }>()
|
|
73
|
+
|
|
74
|
+
/** 记录键 */
|
|
75
|
+
function extractedKeyOf (key: string, kind: CardImageKind): string {
|
|
76
|
+
return String(key ?? '') + '|' + kind
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
/** 会话键 */
|
|
54
80
|
function sessionKeyOf (e: any): string {
|
|
55
81
|
return String(e?.contact?.peer ?? e?.channelId ?? '')
|
|
@@ -72,6 +98,9 @@ function sweep (): void {
|
|
|
72
98
|
for (const [key, item] of store) {
|
|
73
99
|
if (now - item.at > TTL_MS) store.delete(key)
|
|
74
100
|
}
|
|
101
|
+
for (const [key, item] of extracted) {
|
|
102
|
+
if (now - item.at > TTL_MS) extracted.delete(key)
|
|
103
|
+
}
|
|
75
104
|
}
|
|
76
105
|
|
|
77
106
|
/**
|
|
@@ -79,6 +108,9 @@ function sweep (): void {
|
|
|
79
108
|
*
|
|
80
109
|
* **只补传进来的那几项**:详情页发完先记封面,评论渲染完再补评论区,
|
|
81
110
|
* 后一次调用不会把前一次记的封面抹掉。
|
|
111
|
+
*
|
|
112
|
+
* 顺带**重新武装**这次记的那几种图对应的按钮:新卡片要发出去了,
|
|
113
|
+
* 它下面的按钮应该可以再点一次(旧卡片上的那次点击不作数)。
|
|
82
114
|
* @param key {@link cardImageKeyOf}
|
|
83
115
|
* @param patch 要记录的内容
|
|
84
116
|
*/
|
|
@@ -88,12 +120,56 @@ export function rememberCardImages (key: string, patch: { cover?: string, commen
|
|
|
88
120
|
const prev = store.get(key)
|
|
89
121
|
const next: CardImages = prev ?? { key, at: Date.now() }
|
|
90
122
|
next.at = Date.now()
|
|
91
|
-
if (patch.cover)
|
|
92
|
-
|
|
93
|
-
|
|
123
|
+
if (patch.cover) {
|
|
124
|
+
next.cover = patch.cover
|
|
125
|
+
extracted.delete(extractedKeyOf(key, 'cover'))
|
|
126
|
+
}
|
|
127
|
+
if (patch.comment?.length) {
|
|
128
|
+
next.comment = patch.comment
|
|
129
|
+
extracted.delete(extractedKeyOf(key, 'comment'))
|
|
130
|
+
}
|
|
131
|
+
if (patch.commentPics?.length) {
|
|
132
|
+
next.commentPics = patch.commentPics
|
|
133
|
+
extracted.delete(extractedKeyOf(key, 'comment'))
|
|
134
|
+
}
|
|
94
135
|
store.set(key, next)
|
|
95
136
|
}
|
|
96
137
|
|
|
138
|
+
/**
|
|
139
|
+
* 占一次「卡片按钮已经点过了」。**只有 QQ 回调按钮走这里**(手敲指令不调)。
|
|
140
|
+
*
|
|
141
|
+
* 先占后发、发失败再 {@link releaseCardImageExtract}:
|
|
142
|
+
* 反过来「先发成功再记」的话,快速连点会有几次同时通过检查,照样能刷出好几条。
|
|
143
|
+
* @param key 作品键({@link cardImageKeyOf})
|
|
144
|
+
* @param kind 封面还是评论区
|
|
145
|
+
* @param by 点击者(用于日志里认出「谁在刷屏」)
|
|
146
|
+
* @returns 第一次点返回 `true`(放行);之前已经点过返回 `false`
|
|
147
|
+
*/
|
|
148
|
+
export function claimCardImageExtract (key: string, kind: CardImageKind, by: string): boolean {
|
|
149
|
+
if (!key) return true
|
|
150
|
+
sweep()
|
|
151
|
+
const id = extractedKeyOf(key, kind)
|
|
152
|
+
if (extracted.has(id)) return false
|
|
153
|
+
extracted.set(id, { at: Date.now(), by: String(by ?? '') })
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 撤销一次占用(这次没发出去 —— 过期、没图、发送报错 —— 不该算「点过了」) */
|
|
158
|
+
export function releaseCardImageExtract (key: string, kind: CardImageKind): void {
|
|
159
|
+
if (!key) return
|
|
160
|
+
extracted.delete(extractedKeyOf(key, kind))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 这个按钮是不是已经点过了(日志用:带上是谁点的)。
|
|
165
|
+
* @param key 作品键
|
|
166
|
+
* @param kind 封面还是评论区
|
|
167
|
+
*/
|
|
168
|
+
export function recallCardImageExtract (key: string, kind: CardImageKind): { at: number, by: string } | undefined {
|
|
169
|
+
if (!key) return undefined
|
|
170
|
+
return extracted.get(extractedKeyOf(key, kind))
|
|
171
|
+
}
|
|
172
|
+
|
|
97
173
|
/**
|
|
98
174
|
* 记下「这个会话最近解析的是哪个作品」。
|
|
99
175
|
* 用户手敲 `kkk封面`(不带参数)时用它兜底 —— 按钮自己是带参数的,不走这条路。
|