koishi-plugin-kkk 3.11.1 → 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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # 更新日志
2
2
 
3
+ ## 3.12.0
4
+
5
+ ### 新:提取图片的按钮只能点一次
6
+
7
+ 卡片下面「提取封面图 / 提取评论区图片」这两个按钮以前可以一直点。有人一直点,
8
+ 群里就被同一张图刷屏,而且看不出是谁在点。
9
+
10
+ 现在**同一张卡片上的同一个按钮只放行一次**:
11
+
12
+ - 再点不会往群里发任何东西(不再刷屏),但日志里会记下「这次是谁点的、之前是谁点过」,
13
+ 真被人刷了能查出来;
14
+ - 封面和评论区是两个按钮,各点各的,互不影响;换一个作品也照常能点;
15
+ - 想再要一次,**重新发一遍链接**就行,新卡片下面的按钮又是可点的(不会被永久锁死);
16
+ - 自己手敲 `kkk封面` / `kkk评论` 不受这个限制,随时都能用。
17
+
18
+ 还有一处顺手修的:万一这次没发出去(图过期了、或者发送报错),这次点击**不算数**,
19
+ 再点一次还是能发 —— 以前那种「点一次就永久作废」的情况不会出现。
20
+
3
21
  ## 3.11.1
4
22
 
5
23
  这一版修的主要是两类事:**东西发不出去**,和**按钮点了没反应**。
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "3.11.1",
3
- "buildTime": "2026-10-01T13:05:54.560Z",
4
- "buildTimestamp": 1790859954564,
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",
@@ -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 cached = (0, CardImageCache_1.recallCardImages)((0, CardImageCache_1.resolveCardImageKey)(e, arg));
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
- const md = await (0, QqPanel_1.buildMarkdownImageMessage)(urls, 420, (0, ImageSlice_1.platformOf)(e));
686
- await e.reply(md ?? urls.map((url) => node_karin_1.segment.image(url)));
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
- if (patch.comment?.length)
86
+ extracted.delete(extractedKeyOf(key, 'cover'));
87
+ }
88
+ if (patch.comment?.length) {
56
89
  next.comment = patch.comment;
57
- if (patch.commentPics?.length)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.11.1",
3
+ "version": "3.12.0",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -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) => '![cover](' + 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
+ }
@@ -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 { recallCardImages, resolveCardImageKey } from '@/module/utils/CardImageCache'
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 cached = recallCardImages(resolveCardImageKey(e, arg))
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
- const md = await buildMarkdownImageMessage(urls, 420, platformOf(e))
715
- await e.reply(md ?? urls.map((url) => segment.image(url)))
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) next.cover = patch.cover
92
- if (patch.comment?.length) next.comment = patch.comment
93
- if (patch.commentPics?.length) next.commentPics = patch.commentPics
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封面`(不带参数)时用它兜底 —— 按钮自己是带参数的,不走这条路。