@imweapp/openclaw-imwe 2026.4.12-alpha.1

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/index.ts +16 -0
  4. package/openclaw.plugin.json +58 -0
  5. package/package.json +73 -0
  6. package/proto/PbBoxPullProto.proto +43 -0
  7. package/proto/PbChatAudioContent.proto +23 -0
  8. package/proto/PbChatDeliverMsg.proto +38 -0
  9. package/proto/PbChatFileMeta.proto +34 -0
  10. package/proto/PbChatMsg.proto +93 -0
  11. package/proto/PbChatRichMediaContent.proto +31 -0
  12. package/proto/PbChatTextContent.proto +38 -0
  13. package/proto/PbMarkdownContent.proto +18 -0
  14. package/proto/PbMsgReadStampContent.proto +11 -0
  15. package/proto/PbPacket.proto +61 -0
  16. package/proto/PbSingleChatMsg.proto +60 -0
  17. package/setup-entry.ts +17 -0
  18. package/src/accounts.ts +109 -0
  19. package/src/api-client.ts +740 -0
  20. package/src/bot-info-cache.ts +49 -0
  21. package/src/channel.runtime.ts +29 -0
  22. package/src/channel.ts +456 -0
  23. package/src/config-schema.ts +26 -0
  24. package/src/e2ee/api.ts +261 -0
  25. package/src/e2ee/canonical.ts +59 -0
  26. package/src/e2ee/errors.ts +103 -0
  27. package/src/e2ee/index.ts +8 -0
  28. package/src/e2ee/proper-lockfile.d.ts +61 -0
  29. package/src/e2ee/service.ts +1273 -0
  30. package/src/e2ee/store.ts +174 -0
  31. package/src/e2ee/types.ts +113 -0
  32. package/src/e2ee/vodozemac.ts +373 -0
  33. package/src/file-transfer/api.ts +364 -0
  34. package/src/file-transfer/concurrency.ts +77 -0
  35. package/src/file-transfer/download.ts +261 -0
  36. package/src/file-transfer/file-crypto.ts +93 -0
  37. package/src/file-transfer/index.ts +18 -0
  38. package/src/file-transfer/scheduler.ts +185 -0
  39. package/src/file-transfer/types.ts +195 -0
  40. package/src/file-transfer/upload.ts +656 -0
  41. package/src/markdown-detect.ts +119 -0
  42. package/src/media-upload.ts +338 -0
  43. package/src/media-utils.ts +110 -0
  44. package/src/monitor.ts +838 -0
  45. package/src/proto/codec.ts +54 -0
  46. package/src/proto/inbound.codec.ts +624 -0
  47. package/src/proto/proto-types.ts +291 -0
  48. package/src/proto/registry.ts +226 -0
  49. package/src/proto/send.codec.ts +535 -0
  50. package/src/recent-message-cache.ts +350 -0
  51. package/src/send.ts +792 -0
  52. package/src/setup-core.ts +62 -0
  53. package/src/types.ts +153 -0
  54. package/src/vodozemackit/index.ts +297 -0
  55. package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
  56. package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
  57. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
  58. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
  59. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm.d.ts +109 -0
@@ -0,0 +1,350 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ /** 最近消息缓存上限(按会话 scope 隔离) */
5
+ const MAX_RECENT_MESSAGES_PER_ACCOUNT = 200;
6
+
7
+ /** 持久化 scope 上限,避免长期运行后文件无限增长 */
8
+ const MAX_PERSISTED_SCOPES = 1000;
9
+
10
+ /** 最近消息保留 30 天,超过后按 scope 清理 */
11
+ const CACHE_SCOPE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
12
+
13
+ /** debounce 写盘间隔:最后一次 remember 后等待 5s */
14
+ const FLUSH_DEBOUNCE_MS = 5000;
15
+
16
+ /** 最大延迟写盘:首条脏数据后 30s 内必须落盘,防止高频场景下 debounce 饥饿 */
17
+ const FLUSH_MAX_DELAY_MS = 30_000;
18
+
19
+ /** 持久化文件格式版本 */
20
+ const CACHE_FILE_VERSION = 1;
21
+
22
+ export type CachedInboundMessage = {
23
+ body: string;
24
+ senderId: string;
25
+ timestampMs: number;
26
+ };
27
+
28
+ /** 持久化文件结构 */
29
+ type CacheFile = {
30
+ version: number;
31
+ scopes: Record<string, { msgId: string; body: string; senderId: string; ts: number }[]>;
32
+ };
33
+
34
+ type LogFn = (msg: string) => void;
35
+
36
+ /** 按会话 scope 隔离的最近消息缓存:clientMsgId -> 消息正文 */
37
+ const recentMessagesByAccount = new Map<string, Map<string, CachedInboundMessage>>();
38
+
39
+ /**
40
+ * scope 最近活跃时间,用于持久化裁剪。
41
+ *
42
+ * 运行中新写入的消息使用当前时间,而不是消息自带 timestampMs:
43
+ * - timestampMs 来自平台消息时间,历史消息/测试数据可能很旧
44
+ * - 持久化清理关心“这个会话最近是否还在被使用”
45
+ */
46
+ const scopeTouchedAt = new Map<string, number>();
47
+
48
+ // ── 持久化内部状态(模块级单例,多账号共享) ──
49
+ let initialized = false;
50
+ let stateDirPath: string | undefined;
51
+ let logInfo: LogFn | undefined;
52
+ let logWarn: LogFn | undefined;
53
+
54
+ // ── 写入并发控制 ──
55
+ /** 有未刷盘的内存变更 */
56
+ let dirty = false;
57
+ /** 正在执行写盘 */
58
+ let writing = false;
59
+ /** debounce 定时器:最后一次 remember 后 FLUSH_DEBOUNCE_MS 触发 */
60
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
61
+ /** maxDelay 定时器:首次 dirty 后 FLUSH_MAX_DELAY_MS 无条件触发(防饥饿安全阀) */
62
+ let maxDelayTimer: ReturnType<typeof setTimeout> | undefined;
63
+
64
+ function resolveCacheFilePath(): string {
65
+ return path.join(stateDirPath!, 'channel-data', 'imwe', 'recent-message-cache.json');
66
+ }
67
+
68
+ function resolveCacheTmpPath(): string {
69
+ return resolveCacheFilePath() + '.tmp';
70
+ }
71
+
72
+ function getRecentMessageCache(scopeKey: string): Map<string, CachedInboundMessage> {
73
+ const existing = recentMessagesByAccount.get(scopeKey);
74
+ if (existing) return existing;
75
+ const created = new Map<string, CachedInboundMessage>();
76
+ recentMessagesByAccount.set(scopeKey, created);
77
+ return created;
78
+ }
79
+
80
+ function touchScope(scopeKey: string, timestampMs: number): void {
81
+ scopeTouchedAt.set(scopeKey, Math.max(scopeTouchedAt.get(scopeKey) ?? 0, timestampMs));
82
+ }
83
+
84
+ /**
85
+ * 写盘前做轻量裁剪,避免 recent-message-cache.json 随会话数无限增长。
86
+ *
87
+ * 裁剪粒度选择 scope 而不是单条消息:
88
+ * - 单个 scope 内已有 MAX_RECENT_MESSAGES_PER_ACCOUNT 控制消息数量
89
+ * - 按 scope 清理可以保留引用回复所需的局部上下文,行为更可预期
90
+ */
91
+ function pruneCaches(nowMs: number): void {
92
+ const minTimestampMs = nowMs - CACHE_SCOPE_TTL_MS;
93
+ for (const [scopeKey, cache] of recentMessagesByAccount) {
94
+ if (cache.size === 0) {
95
+ recentMessagesByAccount.delete(scopeKey);
96
+ scopeTouchedAt.delete(scopeKey);
97
+ continue;
98
+ }
99
+ const latest = Math.max(...Array.from(cache.values(), (message) => message.timestampMs));
100
+ const touchedAt = scopeTouchedAt.get(scopeKey) ?? latest;
101
+ // 从旧文件恢复的 scope 没有运行期 touch 记录时,退回使用消息时间判断是否过期。
102
+ if (touchedAt > 0 && touchedAt < minTimestampMs) {
103
+ recentMessagesByAccount.delete(scopeKey);
104
+ scopeTouchedAt.delete(scopeKey);
105
+ continue;
106
+ }
107
+ if (!scopeTouchedAt.has(scopeKey)) touchScope(scopeKey, latest);
108
+ }
109
+
110
+ if (recentMessagesByAccount.size <= MAX_PERSISTED_SCOPES) return;
111
+
112
+ // 超过总 scope 上限时,淘汰最久未活跃的 scope,避免每次整文件写盘成本持续上升。
113
+ const sortedScopes = Array.from(recentMessagesByAccount.keys()).sort(
114
+ (a, b) => (scopeTouchedAt.get(a) ?? 0) - (scopeTouchedAt.get(b) ?? 0),
115
+ );
116
+ const removeCount = recentMessagesByAccount.size - MAX_PERSISTED_SCOPES;
117
+ for (const scopeKey of sortedScopes.slice(0, removeCount)) {
118
+ recentMessagesByAccount.delete(scopeKey);
119
+ scopeTouchedAt.delete(scopeKey);
120
+ }
121
+ }
122
+
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ // 持久化:从磁盘加载
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+ function loadFromDisk(): void {
127
+ if (!stateDirPath) return;
128
+ const filePath = resolveCacheFilePath();
129
+ try {
130
+ const raw = fs.readFileSync(filePath, 'utf-8');
131
+ const data = JSON.parse(raw) as CacheFile;
132
+ if (data.version !== CACHE_FILE_VERSION) {
133
+ logWarn?.(`[recent-message-cache] 缓存文件版本不匹配(v${data.version}),跳过恢复`);
134
+ return;
135
+ }
136
+ const scopes = data.scopes;
137
+ if (!scopes || typeof scopes !== 'object') return;
138
+ let scopeCount = 0;
139
+ let entryCount = 0;
140
+ for (const [scopeKey, entries] of Object.entries(scopes)) {
141
+ if (!Array.isArray(entries)) continue;
142
+ const cache = getRecentMessageCache(scopeKey);
143
+ for (const e of entries) {
144
+ if (e.msgId && typeof e.body === 'string') {
145
+ cache.set(e.msgId, {
146
+ body: e.body,
147
+ senderId: e.senderId ?? '',
148
+ timestampMs: e.ts ?? 0,
149
+ });
150
+ touchScope(scopeKey, e.ts ?? 0);
151
+ entryCount++;
152
+ }
153
+ }
154
+ scopeCount++;
155
+ }
156
+ logInfo?.(`[recent-message-cache] 从磁盘恢复 ${scopeCount} 个 scope,${entryCount} 条消息`);
157
+ } catch {
158
+ // 文件不存在或解析失败,使用空缓存
159
+ }
160
+ }
161
+
162
+ // ─────────────────────────────────────────────────────────────────────────────
163
+ // 持久化:写入磁盘(原子写 tmp + rename)
164
+ // ─────────────────────────────────────────────────────────────────────────────
165
+ function writeToDisk(): boolean {
166
+ if (!stateDirPath) return true;
167
+ const filePath = resolveCacheFilePath();
168
+ const tmpPath = resolveCacheTmpPath();
169
+ try {
170
+ pruneCaches(Date.now());
171
+ const data: CacheFile = { version: CACHE_FILE_VERSION, scopes: {} };
172
+ for (const [scopeKey, cache] of recentMessagesByAccount) {
173
+ if (cache.size === 0) continue;
174
+ data.scopes[scopeKey] = Array.from(cache.entries()).map(([msgId, m]) => ({
175
+ msgId,
176
+ body: m.body,
177
+ senderId: m.senderId,
178
+ ts: m.timestampMs,
179
+ }));
180
+ }
181
+ const dir = path.dirname(filePath);
182
+ fs.mkdirSync(dir, { recursive: true });
183
+ // 原子写:先写 tmp 文件,再 rename,防止断电导致文件损坏
184
+ fs.writeFileSync(tmpPath, JSON.stringify(data), 'utf-8');
185
+ fs.renameSync(tmpPath, filePath);
186
+ } catch (err) {
187
+ logWarn?.(`[recent-message-cache] 持久化失败:${String(err)}`);
188
+ // 清理残留 tmp 文件
189
+ try {
190
+ fs.unlinkSync(tmpPath);
191
+ } catch {
192
+ /* ignore */
193
+ }
194
+ return false;
195
+ }
196
+ return true;
197
+ }
198
+
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+ // 写入并发控制:双定时器 + dirty/writing 标志
201
+ // ─────────────────────────────────────────────────────────────────────────────
202
+
203
+ /** 执行刷盘:置 writing 标志,写完后检查 dirty 自动重入 */
204
+ function doFlush(): void {
205
+ writing = true;
206
+ dirty = false;
207
+ clearTimers();
208
+ const ok = writeToDisk();
209
+ writing = false;
210
+ // IO 失败时保留 dirty,下一次 scheduleFlush 会重试当前完整内存状态。
211
+ if (!ok) {
212
+ dirty = true;
213
+ }
214
+ // 写盘期间可能来了新数据,dirty 被重新置 true
215
+ if (dirty) {
216
+ scheduleFlush();
217
+ }
218
+ }
219
+
220
+ function clearTimers(): void {
221
+ if (debounceTimer) {
222
+ clearTimeout(debounceTimer);
223
+ debounceTimer = undefined;
224
+ }
225
+ if (maxDelayTimer) {
226
+ clearTimeout(maxDelayTimer);
227
+ maxDelayTimer = undefined;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * 调度延迟刷盘。
233
+ *
234
+ * 双定时器策略:
235
+ * - debounceTimer:每次 rememberRecentMessage 重置,5s 内无新消息就刷盘
236
+ * - maxDelayTimer:首次 dirty 时设置,30s 后无条件刷盘(防饥饿安全阀)
237
+ *
238
+ * dirty/writing 标志:
239
+ * - writing 期间新数据进来 → 只标 dirty,不重复调度
240
+ * - doFlush 完成后检查 dirty → 自动重入 scheduleFlush
241
+ */
242
+ function scheduleFlush(): void {
243
+ dirty = true;
244
+ // 正在写盘,不重复调度;doFlush 完成后会检查 dirty 自动重入
245
+ if (writing) return;
246
+
247
+ // debounce 定时器:每次重置
248
+ if (debounceTimer) clearTimeout(debounceTimer);
249
+ debounceTimer = setTimeout(() => {
250
+ debounceTimer = undefined;
251
+ doFlush();
252
+ }, FLUSH_DEBOUNCE_MS);
253
+
254
+ // maxDelay 定时器:只设一次(安全阀,防 debounce 饥饿)
255
+ if (!maxDelayTimer) {
256
+ maxDelayTimer = setTimeout(() => {
257
+ maxDelayTimer = undefined;
258
+ doFlush();
259
+ }, FLUSH_MAX_DELAY_MS);
260
+ }
261
+ }
262
+
263
+ // ═════════════════════════════════════════════════════════════════════════════
264
+ // 公共 API
265
+ // ═════════════════════════════════════════════════════════════════════════════
266
+
267
+ /**
268
+ * 初始化缓存:设置持久化目录并从磁盘恢复已有缓存。
269
+ *
270
+ * 幂等:多账号场景下只在首次调用时执行磁盘加载,后续调用为空操作。
271
+ * 应在 channel.ts 的 startAccount 中调用,轮询启动之前。
272
+ */
273
+ export function init(stateDir: string, options?: { logInfo?: LogFn; logWarn?: LogFn }): void {
274
+ if (initialized) return;
275
+ initialized = true;
276
+ stateDirPath = stateDir;
277
+ logInfo = options?.logInfo;
278
+ logWarn = options?.logWarn;
279
+ loadFromDisk();
280
+ }
281
+
282
+ /**
283
+ * 强制将所有内存缓存写入磁盘(清除所有定时器,立即刷盘)。
284
+ *
285
+ * 多账号场景下每个 stopAccount 都可安全调用:写盘是幂等的,
286
+ * 每次调用都会把当前完整内存状态序列化到同一个文件。
287
+ */
288
+ export function forceFlushAll(): void {
289
+ // 正在写盘中 → 不需要重复触发,doFlush 完成后会处理 dirty
290
+ if (writing) {
291
+ dirty = true;
292
+ clearTimers();
293
+ return;
294
+ }
295
+ dirty = false;
296
+ clearTimers();
297
+ if (!writeToDisk()) {
298
+ // stopAccount 场景也可能遇到临时 IO 错误,保留 dirty 并交给后台定时器重试。
299
+ dirty = true;
300
+ scheduleFlush();
301
+ }
302
+ }
303
+
304
+ export function rememberRecentMessage(
305
+ scopeKey: string,
306
+ message: {
307
+ msgId: string;
308
+ body: string;
309
+ senderId: string;
310
+ timestampMs: number;
311
+ },
312
+ ): void {
313
+ const cache = getRecentMessageCache(scopeKey);
314
+ cache.set(message.msgId, {
315
+ body: message.body,
316
+ senderId: message.senderId,
317
+ timestampMs: message.timestampMs,
318
+ });
319
+ // 这里记录运行期活跃时间;消息自身 timestampMs 仍原样保存,用于展示/恢复语义。
320
+ touchScope(scopeKey, Date.now());
321
+ while (cache.size > MAX_RECENT_MESSAGES_PER_ACCOUNT) {
322
+ const oldestKey = cache.keys().next().value;
323
+ if (!oldestKey) break;
324
+ cache.delete(oldestKey);
325
+ }
326
+ if (stateDirPath) {
327
+ scheduleFlush();
328
+ }
329
+ }
330
+
331
+ export function resolveQuotedMessage(
332
+ scopeKey: string,
333
+ referenceClientMsgId: string | undefined,
334
+ ): CachedInboundMessage | undefined {
335
+ if (!referenceClientMsgId) return undefined;
336
+ return getRecentMessageCache(scopeKey).get(referenceClientMsgId);
337
+ }
338
+
339
+ /** 测试用:清空内存缓存和全部持久化状态 */
340
+ export function clearRecentMessagesForTest(): void {
341
+ recentMessagesByAccount.clear();
342
+ scopeTouchedAt.clear();
343
+ clearTimers();
344
+ dirty = false;
345
+ writing = false;
346
+ initialized = false;
347
+ stateDirPath = undefined;
348
+ logInfo = undefined;
349
+ logWarn = undefined;
350
+ }