@jerryliang122/openclaw-qqbot 1.0.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.
Files changed (120) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +967 -0
  3. package/README.zh.md +790 -0
  4. package/dist/index.cjs +18072 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +1222 -0
  7. package/index.ts +92 -0
  8. package/openclaw.plugin.json +38 -0
  9. package/package.json +69 -0
  10. package/preload.cjs +19 -0
  11. package/scripts/link-sdk-core.cjs +268 -0
  12. package/scripts/proactive-api-server.ts +369 -0
  13. package/scripts/send-proactive.ts +293 -0
  14. package/scripts/test-sendmedia.ts +116 -0
  15. package/skills/qqbot-channel/SKILL.md +285 -0
  16. package/skills/qqbot-channel/references/api_references.md +521 -0
  17. package/skills/qqbot-remind/SKILL.md +159 -0
  18. package/skills/qqbot-upgrade/SKILL.md +56 -0
  19. package/src/adapter/contract.ts +63 -0
  20. package/src/adapter/lint.ts +144 -0
  21. package/src/adapter/media.ts +40 -0
  22. package/src/adapter/pairing.ts +95 -0
  23. package/src/adapter/resolve.ts +255 -0
  24. package/src/adapter/setup.ts +13 -0
  25. package/src/adapter/webhook.ts +248 -0
  26. package/src/adapter/workspace.ts +21 -0
  27. package/src/agent-prompt-adapter.ts +26 -0
  28. package/src/bot-instance.ts +60 -0
  29. package/src/channel.ts +230 -0
  30. package/src/commands/bot-approve.ts +143 -0
  31. package/src/commands/bot-clear-storage.ts +114 -0
  32. package/src/commands/bot-group-always.ts +62 -0
  33. package/src/commands/bot-group-info.ts +48 -0
  34. package/src/commands/bot-help.ts +40 -0
  35. package/src/commands/bot-logs.ts +248 -0
  36. package/src/commands/bot-me.ts +18 -0
  37. package/src/commands/bot-pairing.ts +50 -0
  38. package/src/commands/bot-ping.ts +33 -0
  39. package/src/commands/bot-streaming.ts +55 -0
  40. package/src/commands/bot-upgrade.ts +56 -0
  41. package/src/commands/bot-version.ts +41 -0
  42. package/src/commands/config-util.ts +96 -0
  43. package/src/commands/index.ts +51 -0
  44. package/src/config.ts +403 -0
  45. package/src/constants.ts +6 -0
  46. package/src/dispatch/body-assembler.ts +308 -0
  47. package/src/dispatch/ctx-builder.ts +127 -0
  48. package/src/dispatch/dispatch.ts +667 -0
  49. package/src/dispatch/envelope-builder.ts +112 -0
  50. package/src/dispatch/index.ts +2 -0
  51. package/src/features/approval-capability.ts +302 -0
  52. package/src/features/approval-helpers.ts +271 -0
  53. package/src/features/approval-utils.ts +21 -0
  54. package/src/features/command-panel.ts +301 -0
  55. package/src/features/credential-backup.ts +74 -0
  56. package/src/features/group-mode-store.ts +79 -0
  57. package/src/features/history-store.ts +75 -0
  58. package/src/features/msgid-cache.ts +55 -0
  59. package/src/features/outbound-echo-store.ts +46 -0
  60. package/src/features/proactive-budget.ts +57 -0
  61. package/src/features/proactive.ts +549 -0
  62. package/src/features/question-helpers.ts +771 -0
  63. package/src/features/quota-manager.ts +173 -0
  64. package/src/features/ref-index-store.ts +289 -0
  65. package/src/features/secret-input-store.ts +118 -0
  66. package/src/features/secret-store-cli.ts +324 -0
  67. package/src/features/typing-refresh.ts +51 -0
  68. package/src/features/update-checker.ts +166 -0
  69. package/src/gateway/event-handlers.ts +456 -0
  70. package/src/gateway/index.ts +3 -0
  71. package/src/gateway/lifecycle.ts +236 -0
  72. package/src/gateway/middleware-setup.ts +173 -0
  73. package/src/gateway/qqbot-gateway.ts +458 -0
  74. package/src/gateway-adapter.ts +44 -0
  75. package/src/heartbeat-adapter.ts +57 -0
  76. package/src/message-adapter.ts +40 -0
  77. package/src/messaging-adapter.ts +78 -0
  78. package/src/middleware/access-control.ts +125 -0
  79. package/src/middleware/attachment.ts +373 -0
  80. package/src/middleware/inbound-guard.ts +102 -0
  81. package/src/middleware/policy-injector.ts +71 -0
  82. package/src/middleware/secret-capture.ts +161 -0
  83. package/src/middleware/typing.ts +110 -0
  84. package/src/openclaw-plugin-sdk.d.ts +543 -0
  85. package/src/outbound/chunker.ts +80 -0
  86. package/src/outbound/debounce.ts +102 -0
  87. package/src/outbound/deliver-pipeline.ts +235 -0
  88. package/src/outbound/index.ts +3 -0
  89. package/src/outbound/local-file-router.ts +145 -0
  90. package/src/outbound/media-send.ts +408 -0
  91. package/src/outbound/outbound-service.ts +298 -0
  92. package/src/outbound/reply-limiter.ts +139 -0
  93. package/src/outbound/sanitize.ts +32 -0
  94. package/src/outbound/streaming-controller.ts +332 -0
  95. package/src/outbound/target.ts +109 -0
  96. package/src/outbound-adapter.ts +323 -0
  97. package/src/plugin-base.ts +42 -0
  98. package/src/request-context.ts +50 -0
  99. package/src/runtime.ts +42 -0
  100. package/src/setup/account-key.ts +41 -0
  101. package/src/setup/finalize.ts +110 -0
  102. package/src/setup/login.ts +197 -0
  103. package/src/setup/surface.ts +40 -0
  104. package/src/status-adapter.ts +56 -0
  105. package/src/tools/platform.ts +149 -0
  106. package/src/tools/remind.ts +308 -0
  107. package/src/tools/secret-input.ts +185 -0
  108. package/src/types-augment.d.ts +54 -0
  109. package/src/types-plugin.ts +82 -0
  110. package/src/types.ts +620 -0
  111. package/src/typing-lifecycle.ts +182 -0
  112. package/src/utils/mention.ts +52 -0
  113. package/src/utils/pkg-version.ts +23 -0
  114. package/src/utils/platform.ts +459 -0
  115. package/src/utils/plugin-logger.ts +104 -0
  116. package/src/utils/ssrf-guard.ts +132 -0
  117. package/src/utils/stt.ts +150 -0
  118. package/src/utils/voice-text.ts +61 -0
  119. package/tsconfig.json +17 -0
  120. package/tsup.config.ts +64 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * QQBot 被动回复配额管理
3
+ *
4
+ * C2C: 4 次/msg_id, 60 分钟
5
+ * Group: 5 次/msg_id, 5 分钟
6
+ *
7
+ * 重要说明:
8
+ * - QQ Bot 平台的 msg_id 有时效性限制(C2C: 60分钟,Group: 5分钟)
9
+ * - 过期后的 msg_id 不能再用于被动回复(API 会返回错误 40034128)
10
+ * - 这是平台限制,不是"配额恢复"
11
+ * - 因此 checkPassiveReplyQuota 在 msg_id 过期时返回 false,而非重置配额
12
+ *
13
+ * 使用方式:
14
+ * 1. 推荐:使用 checkAndConsumePassiveReplyQuota 进行原子操作(失败用 rollback 回滚)
15
+ * 2. 纯探测(不消耗配额):checkPassiveReplyQuota
16
+ */
17
+
18
+ import type { QuotaState, QuotaCheckParams } from '../types-plugin.js';
19
+
20
+ const quotaCache = new Map<string, QuotaState>();
21
+ const MAX_CACHE_SIZE = 10000;
22
+
23
+ const QUOTA_LIMITS = {
24
+ c2c: { count: 4, ttlMs: 60 * 60 * 1000 },
25
+ group: { count: 5, ttlMs: 5 * 60 * 1000 },
26
+ };
27
+
28
+ /**
29
+ * 检查被动回复配额(纯探测,不消耗配额)。
30
+ * 需要同时消耗配额时使用 checkAndConsumePassiveReplyQuota。
31
+ */
32
+ export function checkPassiveReplyQuota(params: QuotaCheckParams): boolean {
33
+ const { accountId, msgId, scope } = params;
34
+
35
+ if (!msgId) {
36
+ return false;
37
+ }
38
+
39
+ const key = `${accountId}:${scope}:${msgId}`;
40
+ const now = Date.now();
41
+
42
+ const cached = quotaCache.get(key);
43
+ if (cached) {
44
+ // msg_id 过期后不能再用于被动回复
45
+ // 这是 QQ Bot 平台限制,API 会返回错误 40034128
46
+ if (now > cached.expiresAt) {
47
+ quotaCache.delete(key);
48
+ return false;
49
+ }
50
+
51
+ const limit = QUOTA_LIMITS[scope].count;
52
+ if (cached.count >= limit) {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ return true;
58
+ }
59
+
60
+ /**
61
+ * 回滚被动回复配额(API 调用失败时使用)
62
+ */
63
+ export function rollbackPassiveReplyQuota(params: {
64
+ accountId: string;
65
+ msgId: string;
66
+ scope: 'c2c' | 'group';
67
+ }): void {
68
+ const { accountId, msgId, scope } = params;
69
+ const key = `${accountId}:${scope}:${msgId}`;
70
+
71
+ const cached = quotaCache.get(key);
72
+ if (cached && cached.count > 0) {
73
+ cached.count -= 1;
74
+ quotaCache.set(key, cached);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 原子操作:检查并消耗被动回复配额
80
+ * 推荐使用此函数,避免 check-then-consume 竞态
81
+ */
82
+ export function checkAndConsumePassiveReplyQuota(
83
+ params: QuotaCheckParams & { log?: { debug?: (message: string) => void } },
84
+ ): { canReply: boolean; rollback: () => void } {
85
+ const { accountId, msgId, scope, log } = params;
86
+
87
+ if (!msgId) {
88
+ return { canReply: false, rollback: () => {} };
89
+ }
90
+
91
+ const key = `${accountId}:${scope}:${msgId}`;
92
+ const now = Date.now();
93
+ const ttl = QUOTA_LIMITS[scope].ttlMs;
94
+
95
+ let cached = quotaCache.get(key);
96
+
97
+ // 检查过期:msg_id 过期后不能用于被动回复
98
+ // 这是 QQ Bot 平台限制,API 会返回错误 40034128
99
+ if (cached && now > cached.expiresAt) {
100
+ // msg_id 已过期,不能用于被动回复
101
+ return { canReply: false, rollback: () => {} };
102
+ }
103
+
104
+ // 检查配额
105
+ const limit = QUOTA_LIMITS[scope].count;
106
+ if (cached && cached.count >= limit) {
107
+ return { canReply: false, rollback: () => {} };
108
+ }
109
+
110
+ // 原子消耗
111
+ cached = cached || { count: 0, expiresAt: now + ttl };
112
+ cached.count += 1;
113
+ quotaCache.set(key, cached);
114
+
115
+ // 缓存大小控制
116
+ if (quotaCache.size > MAX_CACHE_SIZE) {
117
+ const oldestKey = quotaCache.keys().next().value;
118
+ if (oldestKey) {
119
+ quotaCache.delete(oldestKey);
120
+ }
121
+ }
122
+
123
+ log?.debug?.(`[${accountId}] consumed passive quota: ${key} count=${cached.count}`);
124
+
125
+ // 返回回滚函数
126
+ const rollback = () => {
127
+ const state = quotaCache.get(key);
128
+ if (state && state.count > 0) {
129
+ state.count -= 1;
130
+ quotaCache.set(key, state);
131
+ }
132
+ };
133
+
134
+ return { canReply: true, rollback };
135
+ }
136
+
137
+ export function inferQQBotScope(to: string): 'c2c' | 'group' {
138
+ const parts = to.split(':');
139
+ const scope = parts[1];
140
+ return scope === 'group' ? 'group' : 'c2c';
141
+ }
142
+
143
+ export function clearQuotaCache(): void {
144
+ quotaCache.clear();
145
+ }
146
+
147
+ export function clearQuotaCacheForAccount(accountId: string): void {
148
+ const prefix = `${accountId}:`;
149
+ for (const key of quotaCache.keys()) {
150
+ if (key.startsWith(prefix)) quotaCache.delete(key);
151
+ }
152
+ }
153
+
154
+ export function getQuotaStats(accountId: string, scope: 'c2c' | 'group'): {
155
+ activeSessions: number;
156
+ totalUsage: number;
157
+ } {
158
+ let activeSessions = 0;
159
+ let totalUsage = 0;
160
+
161
+ for (const [key, state] of quotaCache.entries()) {
162
+ if (key.startsWith(`${accountId}:${scope}:`)) {
163
+ activeSessions += 1;
164
+ totalUsage += state.count;
165
+ }
166
+ }
167
+
168
+ return { activeSessions, totalUsage };
169
+ }
170
+
171
+ export function __test_getQuotaCache(): Map<string, QuotaState> {
172
+ return quotaCache;
173
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * 持久化 RefIndex 存储
3
+ *
4
+ * SDK 提供的 MemoryRefIndexStore 在进程重启后即丢失,
5
+ * 而 QQ 引用消息(REFIDX_xxx)入站事件只携带 key,必须本地缓存才能回查。
6
+ *
7
+ * 设计:
8
+ * - 内存 LRU(与 SDK 同款)保证 O(1) 读取
9
+ * - JSONL 追加写持久化,进程重启时按时间顺序回放重建 LRU
10
+ * - 写入触发 compact 阈值时重写文件(去重 + 截断到 maxEntries)
11
+ * - 文件路径: ~/.openclaw/qqbot/data/ref-index.jsonl
12
+ *
13
+ * 实现 SDK 的 RefIndexStore 接口,可直接通过 `quoteRef({ store })` 注入。
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import type { RefEntry, RefIndexStore } from '@tencent-connect/qqbot-nodejs';
19
+ import { getQQBotDataDir } from '../utils/platform.js';
20
+ import { createPluginLogger, type PluginLogger } from '../utils/plugin-logger.js';
21
+
22
+ // 惰性初始化:plugin-logger → runtime → 本模块 → plugin-logger 存在循环导入,
23
+ // 顶层直接 createPluginLogger 会在部分求值顺序下踩到未初始化的 tsx helper
24
+ // (__name is not a function,2026-09-09 群聊架构改造触发)。首次使用时再建。
25
+ let _log: PluginLogger | undefined;
26
+ function log(): PluginLogger {
27
+ return (_log ??= createPluginLogger({ prefix: '[ref-index]' }));
28
+ }
29
+
30
+
31
+ // ── 常量 ──
32
+
33
+ const DEFAULT_MAX_ENTRIES = 50000;
34
+ const DEFAULT_FILENAME = 'ref-index.jsonl';
35
+ /** 当磁盘 line 数超过 maxEntries * COMPACT_RATIO 时触发 compact */
36
+ const COMPACT_RATIO = 2;
37
+ /** 内存 LRU 的最小容量保护值 */
38
+ const MIN_MAX_ENTRIES = 100;
39
+
40
+ // ── 磁盘行结构 ──
41
+
42
+ interface DiskLine {
43
+ /** RefIndex 键,例如 REFIDX_xxx 或 messageId */
44
+ k: string;
45
+ /** RefEntry 内容 */
46
+ v: RefEntry;
47
+ /** 写入时间戳(毫秒) */
48
+ t: number;
49
+ }
50
+
51
+ // ── 持久化 Store ──
52
+
53
+ export interface PersistedRefIndexStoreOptions {
54
+ /** 内存与磁盘的最大条目数。默认 2000。 */
55
+ maxEntries?: number;
56
+ /** 自定义存储文件路径。默认 ~/.openclaw/qqbot/data/ref-index.jsonl */
57
+ filePath?: string;
58
+ }
59
+
60
+ /**
61
+ * 持久化版本的 RefIndexStore
62
+ *
63
+ * - get:仅查内存(启动时从磁盘回放重建)
64
+ * - set:内存 + JSONL 追加写;磁盘行数过多时触发 compact
65
+ */
66
+ export class PersistedRefIndexStore implements RefIndexStore {
67
+ private readonly memory = new Map<string, RefEntry>();
68
+ private readonly maxEntries: number;
69
+ private readonly filePath: string;
70
+ /** 当前磁盘累计写入的行数(用于 compact 阈值判断) */
71
+ private diskLineCount = 0;
72
+ /** 是否已成功初始化(磁盘回放完成) */
73
+ private initialized = false;
74
+ /** 串行化写入,防止并发 append 撕裂行 */
75
+ private writeChain: Promise<void> = Promise.resolve();
76
+
77
+ constructor(options: PersistedRefIndexStoreOptions = {}) {
78
+ this.maxEntries = Math.max(options.maxEntries ?? DEFAULT_MAX_ENTRIES, MIN_MAX_ENTRIES);
79
+ this.filePath = options.filePath ?? path.join(getQQBotDataDir('data'), DEFAULT_FILENAME);
80
+ this.init();
81
+ }
82
+
83
+ /**
84
+ * 初始化:按时间顺序回放 JSONL 重建内存 LRU
85
+ */
86
+ private init(): void {
87
+ try {
88
+ if (!fs.existsSync(this.filePath)) {
89
+ this.initialized = true;
90
+ return;
91
+ }
92
+ const raw = fs.readFileSync(this.filePath, 'utf8');
93
+ const lines = raw.split('\n').filter((l) => l.length > 0);
94
+ this.diskLineCount = lines.length;
95
+
96
+ // 按时间排序回放(防止文件被外部修改后乱序)
97
+ const parsed: DiskLine[] = [];
98
+ for (const line of lines) {
99
+ try {
100
+ const obj = JSON.parse(line) as DiskLine;
101
+ if (obj?.k && obj?.v) {
102
+ parsed.push(obj);
103
+ }
104
+ } catch {
105
+ // 跳过损坏行
106
+ }
107
+ }
108
+ parsed.sort((a, b) => a.t - b.t);
109
+
110
+ for (const { k, v } of parsed) {
111
+ this.touchMemory(k, v);
112
+ }
113
+
114
+ // 启动时若磁盘行数已超阈值,立刻 compact 一次
115
+ if (this.diskLineCount > this.maxEntries * COMPACT_RATIO) {
116
+ this.compactSync();
117
+ }
118
+ } catch (err) {
119
+ log().error(
120
+ `init failed: ${err instanceof Error ? err.message : String(err)}`,
121
+ );
122
+ } finally {
123
+ this.initialized = true;
124
+ }
125
+ }
126
+
127
+ // ── RefIndexStore 接口 ──
128
+
129
+ get(key: string): RefEntry | undefined {
130
+ return this.memory.get(key);
131
+ }
132
+
133
+ set(key: string, entry: RefEntry): void {
134
+ this.touchMemory(key, entry);
135
+ // 追加写持久化(串行化)
136
+ this.writeChain = this.writeChain.then(() => this.appendToDisk(key, entry));
137
+ }
138
+
139
+ // ── 内部:内存 LRU 维护 ──
140
+
141
+ private touchMemory(key: string, entry: RefEntry): void {
142
+ // 已存在则先删除再插入,保证插入顺序 = LRU 顺序
143
+ if (this.memory.has(key)) {
144
+ this.memory.delete(key);
145
+ } else if (this.memory.size >= this.maxEntries) {
146
+ // 容量已满 → 淘汰最旧的(Map 的迭代顺序即插入顺序)
147
+ const oldest = this.memory.keys().next().value;
148
+ if (oldest !== undefined) {
149
+ this.memory.delete(oldest);
150
+ }
151
+ }
152
+ this.memory.set(key, entry);
153
+ }
154
+
155
+ // ── 内部:磁盘追加 + compact ──
156
+
157
+ private async appendToDisk(key: string, entry: RefEntry): Promise<void> {
158
+ const line: DiskLine = { k: key, v: entry, t: Date.now() };
159
+ const text = JSON.stringify(line) + '\n';
160
+ try {
161
+ await fs.promises.appendFile(this.filePath, text, 'utf8');
162
+ this.diskLineCount += 1;
163
+ if (this.diskLineCount > this.maxEntries * COMPACT_RATIO) {
164
+ await this.compact();
165
+ }
166
+ } catch (err) {
167
+ log().error(
168
+ `append failed: ${err instanceof Error ? err.message : String(err)}`,
169
+ );
170
+ }
171
+ }
172
+
173
+ /**
174
+ * 异步 compact:将内存 LRU 状态完整重写到磁盘,丢弃历史冗余。
175
+ */
176
+ private async compact(): Promise<void> {
177
+ const tmpPath = `${this.filePath}.tmp`;
178
+ try {
179
+ const now = Date.now();
180
+ // Map 迭代顺序 = 插入顺序(旧 → 新),保持 LRU 顺序
181
+ const lines: string[] = [];
182
+ for (const [k, v] of this.memory.entries()) {
183
+ lines.push(JSON.stringify({ k, v, t: now } satisfies DiskLine));
184
+ }
185
+ const content = lines.length > 0 ? lines.join('\n') + '\n' : '';
186
+ await fs.promises.writeFile(tmpPath, content, 'utf8');
187
+ await fs.promises.rename(tmpPath, this.filePath);
188
+ this.diskLineCount = lines.length;
189
+ log().info(
190
+ `compacted to ${lines.length} entries`,
191
+ );
192
+ } catch (err) {
193
+ log().error(
194
+ `compact failed: ${err instanceof Error ? err.message : String(err)}`,
195
+ );
196
+ // 清理可能残留的 tmp 文件
197
+ try {
198
+ await fs.promises.unlink(tmpPath);
199
+ } catch {
200
+ // ignore
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 同步 compact(仅 init 阶段使用,避免回放后立刻保留巨大磁盘文件)
207
+ */
208
+ private compactSync(): void {
209
+ const tmpPath = `${this.filePath}.tmp`;
210
+ try {
211
+ const now = Date.now();
212
+ const lines: string[] = [];
213
+ for (const [k, v] of this.memory.entries()) {
214
+ lines.push(JSON.stringify({ k, v, t: now } satisfies DiskLine));
215
+ }
216
+ const content = lines.length > 0 ? lines.join('\n') + '\n' : '';
217
+ fs.writeFileSync(tmpPath, content, 'utf8');
218
+ fs.renameSync(tmpPath, this.filePath);
219
+ this.diskLineCount = lines.length;
220
+ } catch (err) {
221
+ log().error(
222
+ `compactSync failed: ${err instanceof Error ? err.message : String(err)}`,
223
+ );
224
+ }
225
+ }
226
+
227
+ // ── 诊断 ──
228
+
229
+ /** 当前内存中的条目数 */
230
+ get size(): number {
231
+ return this.memory.size;
232
+ }
233
+
234
+ /** 是否已完成初始化(磁盘回放) */
235
+ get isInitialized(): boolean {
236
+ return this.initialized;
237
+ }
238
+
239
+ /** 诊断快照 */
240
+ stats(): {
241
+ memoryEntries: number;
242
+ diskLines: number;
243
+ maxEntries: number;
244
+ filePath: string;
245
+ } {
246
+ return {
247
+ memoryEntries: this.memory.size,
248
+ diskLines: this.diskLineCount,
249
+ maxEntries: this.maxEntries,
250
+ filePath: this.filePath,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * 强制将当前内存状态持久化到磁盘(进程退出前调用)
256
+ */
257
+ flush(): void {
258
+ this.compactSync();
259
+ }
260
+ }
261
+
262
+ // ── 默认单例(按 accountId 隔离) ──
263
+
264
+ const stores = new Map<string, PersistedRefIndexStore>();
265
+
266
+ /**
267
+ * 获取按 accountId 隔离的持久化 RefIndexStore 单例。
268
+ *
269
+ * 每个账户独立存储文件,避免多账户混用同一个 refIdx 命名空间。
270
+ */
271
+
272
+ export function getPersistedRefIndexStore(accountId: string): PersistedRefIndexStore {
273
+ let store = stores.get(accountId);
274
+ if (!store) {
275
+ const filePath = path.join(getQQBotDataDir('data', accountId), DEFAULT_FILENAME);
276
+ store = new PersistedRefIndexStore({ filePath });
277
+ stores.set(accountId, store);
278
+ }
279
+ return store;
280
+ }
281
+
282
+ /**
283
+ * 进程退出前 flush 所有 store
284
+ */
285
+ export function flushAllRefIndexStores(): void {
286
+ for (const store of stores.values()) {
287
+ store.flush();
288
+ }
289
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 密钥输入 pending 状态机
3
+ *
4
+ * qqbot_secret_input 工具发卡时登记,secretCapture 中间件消费。
5
+ * 与 multi-question 暂存(question-helpers.ts)同款模式:模块级 Map +
6
+ * TTL setTimeout(unref) 清理。
7
+ *
8
+ * key 绑定 `accountId + c2c senderOpenid` —— pending 天然 per-user:
9
+ * 只有发起密钥输入的同一私聊用户的消息会被捕获,群聊不参与。
10
+ */
11
+ import type { SecretStoreKind } from './secret-store-cli.js';
12
+
13
+ export const DEFAULT_SECRET_INPUT_TTL_MS = 10 * 60 * 1000;
14
+
15
+ export interface PendingSecretInput {
16
+ accountId: string;
17
+ senderOpenid: string;
18
+ /** 环境变量名(已通过 isValidSecretName 校验) */
19
+ name: string;
20
+ /** 已判定写入方式 */
21
+ kind: SecretStoreKind;
22
+ /** 用途说明(展示在卡片上,可选) */
23
+ description?: string;
24
+ createdAt: number;
25
+ expiresAtMs: number;
26
+ }
27
+
28
+ interface StoredPendingSecretInput extends PendingSecretInput {
29
+ cleanupTimer: ReturnType<typeof setTimeout>;
30
+ }
31
+
32
+ const pendingSecretInputs = new Map<string, StoredPendingSecretInput>();
33
+
34
+ export function secretInputKey(accountId: string, senderOpenid: string): string {
35
+ return `${accountId}:c2c:${senderOpenid}`;
36
+ }
37
+
38
+ /**
39
+ * 登记一次密钥输入等待。必须在发送卡片之前调用(避免「先发后登」竞态)。
40
+ * 同一用户重复发起直接覆盖旧条目(并清理旧 timer)。
41
+ */
42
+ export function registerPendingSecretInput(
43
+ entry: Omit<PendingSecretInput, 'expiresAtMs'>,
44
+ ttlMs: number = DEFAULT_SECRET_INPUT_TTL_MS,
45
+ ): void {
46
+ const key = secretInputKey(entry.accountId, entry.senderOpenid);
47
+ const existing = pendingSecretInputs.get(key);
48
+ if (existing) clearTimeout(existing.cleanupTimer);
49
+
50
+ const stored: StoredPendingSecretInput = {
51
+ ...entry,
52
+ expiresAtMs: Date.now() + ttlMs,
53
+ cleanupTimer: setTimeout(() => {
54
+ if (pendingSecretInputs.get(key) === stored) {
55
+ pendingSecretInputs.delete(key);
56
+ }
57
+ }, ttlMs),
58
+ };
59
+ stored.cleanupTimer.unref?.();
60
+ pendingSecretInputs.set(key, stored);
61
+ }
62
+
63
+ function lookup(accountId: string, senderOpenid: string): StoredPendingSecretInput | undefined {
64
+ const key = secretInputKey(accountId, senderOpenid);
65
+ const entry = pendingSecretInputs.get(key);
66
+ if (!entry) return undefined;
67
+ if (entry.expiresAtMs <= Date.now()) {
68
+ clearTimeout(entry.cleanupTimer);
69
+ pendingSecretInputs.delete(key);
70
+ return undefined;
71
+ }
72
+ return entry;
73
+ }
74
+
75
+ /** 查询进行中的密钥输入(过期条目惰性清除),不消费 */
76
+ export function findPendingSecretInput(
77
+ accountId: string,
78
+ senderOpenid: string,
79
+ ): PendingSecretInput | undefined {
80
+ const entry = lookup(accountId, senderOpenid);
81
+ if (!entry) return undefined;
82
+ const { cleanupTimer: _timer, ...rest } = entry;
83
+ return rest;
84
+ }
85
+
86
+ /** 一次性消费:取出并删除(防同一条 pending 被处理两次) */
87
+ export function takePendingSecretInput(
88
+ accountId: string,
89
+ senderOpenid: string,
90
+ ): PendingSecretInput | undefined {
91
+ const entry = lookup(accountId, senderOpenid);
92
+ if (!entry) return undefined;
93
+ clearTimeout(entry.cleanupTimer);
94
+ pendingSecretInputs.delete(secretInputKey(accountId, senderOpenid));
95
+ const { cleanupTimer: _timer, ...rest } = entry;
96
+ return rest;
97
+ }
98
+
99
+ /** 主动取消(用户发送「取消」/ 发卡失败回滚) */
100
+ export function cancelPendingSecretInput(accountId: string, senderOpenid: string): boolean {
101
+ const key = secretInputKey(accountId, senderOpenid);
102
+ const entry = pendingSecretInputs.get(key);
103
+ if (!entry) return false;
104
+ clearTimeout(entry.cleanupTimer);
105
+ pendingSecretInputs.delete(key);
106
+ return true;
107
+ }
108
+
109
+ /** 测试辅助 */
110
+ export function pendingSecretInputCount(): number {
111
+ return pendingSecretInputs.size;
112
+ }
113
+
114
+ /** 测试辅助:清空全部 pending(不触发 timer 回调) */
115
+ export function clearPendingSecretInputs(): void {
116
+ for (const entry of pendingSecretInputs.values()) clearTimeout(entry.cleanupTimer);
117
+ pendingSecretInputs.clear();
118
+ }