@invago/mixin 1.0.18 → 1.0.19

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.
@@ -2,7 +2,7 @@
2
2
  "id": "mixin",
3
3
  "name": "Mixin Messenger Channel",
4
4
  "description": "Mixin Messenger channel via Blaze WebSocket",
5
- "version": "1.0.18",
5
+ "version": "1.0.19",
6
6
  "channels": [
7
7
  "mixin"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invago/mixin",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "description": "Mixin Messenger channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -1669,9 +1669,7 @@ export async function handleMixinMessage(params: {
1669
1669
  },
1670
1670
  });
1671
1671
  } finally {
1672
- if (!committed) {
1673
- releaseMixinInboundMessage(dedupeKey);
1674
- }
1672
+ await releaseMixinInboundMessage(dedupeKey);
1675
1673
  }
1676
1674
  }
1677
1675
 
@@ -1741,8 +1739,6 @@ export async function handleMixinSystemConversation(params: {
1741
1739
  const welcomeText = formatMixinWelcomeMessage(joinedProfiles.map((profile) => profile.fullName));
1742
1740
  await sendTextMessage(cfg, accountId, msg.conversationId, undefined, welcomeText, log);
1743
1741
  } finally {
1744
- if (!committed) {
1745
- releaseMixinInboundMessage(dedupeKey);
1746
- }
1742
+ await releaseMixinInboundMessage(dedupeKey);
1747
1743
  }
1748
1744
  }
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
1
+ import crypto from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { getMixinRuntime } from "./runtime.js";
@@ -9,6 +10,7 @@ const DEFAULT_MAX_ENTRIES = 5000;
9
10
  const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
10
11
  const DEFAULT_STALE_MESSAGE_MS = 30 * 60 * 1000;
11
12
  const PERSIST_DEBOUNCE_MS = 1000;
13
+ const CLAIM_STALE_MS = 10 * 60 * 1000;
12
14
 
13
15
  type MixinInboundDedupStoreEntry = {
14
16
  key: string;
@@ -29,6 +31,7 @@ type MixinInboundDedupState = {
29
31
  sweepTimer: NodeJS.Timeout | null;
30
32
  dirty: boolean;
31
33
  lastSweepAt: number;
34
+ lastLoadedAt: number;
32
35
  };
33
36
 
34
37
  type ClaimMixinInboundMessageParams = {
@@ -60,6 +63,7 @@ function createDefaultState(): MixinInboundDedupState {
60
63
  sweepTimer: null,
61
64
  dirty: false,
62
65
  lastSweepAt: 0,
66
+ lastLoadedAt: 0,
63
67
  };
64
68
  }
65
69
 
@@ -95,6 +99,7 @@ function resolveDedupPaths(): {
95
99
  dedupDir: string;
96
100
  dedupFile: string;
97
101
  dedupTmpFile: string;
102
+ claimsDir: string;
98
103
  } {
99
104
  const dedupDir = resolveDedupDir();
100
105
  const dedupFile = path.join(dedupDir, "mixin-inbound-dedup.json");
@@ -102,9 +107,19 @@ function resolveDedupPaths(): {
102
107
  dedupDir,
103
108
  dedupFile,
104
109
  dedupTmpFile: `${dedupFile}.tmp`,
110
+ claimsDir: path.join(dedupDir, "claims"),
105
111
  };
106
112
  }
107
113
 
114
+ function hashDedupeKey(dedupeKey: string): string {
115
+ return crypto.createHash("sha1").update(dedupeKey).digest("hex");
116
+ }
117
+
118
+ function resolveClaimFilePath(dedupeKey: string): string {
119
+ const { claimsDir } = resolveDedupPaths();
120
+ return path.join(claimsDir, `${hashDedupeKey(dedupeKey)}.lock`);
121
+ }
122
+
108
123
  function normalizeTimestamp(value: unknown): number | null {
109
124
  if (typeof value !== "number" || !Number.isFinite(value)) {
110
125
  return null;
@@ -228,13 +243,10 @@ async function persistState(log?: { warn: (message: string) => void }): Promise<
228
243
  await state.persistChain;
229
244
  }
230
245
 
231
- async function ensureLoaded(log?: { warn: (message: string) => void }): Promise<void> {
246
+ async function loadStateFromDisk(log?: { warn: (message: string) => void }): Promise<void> {
232
247
  const state = getState();
233
- if (state.loaded) {
234
- return;
235
- }
236
-
237
248
  const { dedupFile } = resolveDedupPaths();
249
+ state.seen.clear();
238
250
  try {
239
251
  const raw = await readFile(dedupFile, "utf-8");
240
252
  const parsed = JSON.parse(raw) as Partial<MixinInboundDedupStore>;
@@ -260,15 +272,96 @@ async function ensureLoaded(log?: { warn: (message: string) => void }): Promise<
260
272
  }
261
273
 
262
274
  state.loaded = true;
275
+ state.lastLoadedAt = Date.now();
263
276
  pruneSeenEntries(Date.now());
264
277
  ensureSweepTimer(log);
265
278
  }
266
279
 
280
+ async function ensureLoaded(log?: { warn: (message: string) => void }): Promise<void> {
281
+ const state = getState();
282
+ if (state.loaded) {
283
+ return;
284
+ }
285
+ await loadStateFromDisk(log);
286
+ }
287
+
288
+ async function refreshStateFromDisk(log?: { warn: (message: string) => void }): Promise<void> {
289
+ const state = getState();
290
+ const { dedupFile } = resolveDedupPaths();
291
+ try {
292
+ const fileStat = await stat(dedupFile);
293
+ if (!state.loaded || fileStat.mtimeMs > state.lastLoadedAt) {
294
+ await loadStateFromDisk(log);
295
+ }
296
+ } catch (err) {
297
+ if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
298
+ if (!state.loaded) {
299
+ state.loaded = true;
300
+ state.lastLoadedAt = Date.now();
301
+ ensureSweepTimer(log);
302
+ }
303
+ return;
304
+ }
305
+ log?.warn(
306
+ `[mixin] failed to refresh inbound dedup store: ${err instanceof Error ? err.message : String(err)}`,
307
+ );
308
+ }
309
+ }
310
+
267
311
  function shouldSweep(now: number): boolean {
268
312
  const state = getState();
269
313
  return now - state.lastSweepAt >= DEFAULT_SWEEP_INTERVAL_MS;
270
314
  }
271
315
 
316
+ async function claimCrossProcessLock(
317
+ dedupeKey: string,
318
+ log?: { warn: (message: string) => void },
319
+ ): Promise<boolean> {
320
+ const { claimsDir } = resolveDedupPaths();
321
+ const claimFile = resolveClaimFilePath(dedupeKey);
322
+ await mkdir(claimsDir, { recursive: true });
323
+
324
+ for (let attempt = 0; attempt < 2; attempt++) {
325
+ try {
326
+ const handle = await open(claimFile, "wx");
327
+ try {
328
+ await handle.writeFile(JSON.stringify({ dedupeKey, claimedAt: Date.now(), pid: process.pid }), "utf-8");
329
+ } finally {
330
+ await handle.close();
331
+ }
332
+ return true;
333
+ } catch (err) {
334
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
335
+ if (code !== "EEXIST") {
336
+ log?.warn(
337
+ `[mixin] failed to create inbound dedup claim file: ${err instanceof Error ? err.message : String(err)}`,
338
+ );
339
+ return false;
340
+ }
341
+ try {
342
+ const claimStat = await stat(claimFile);
343
+ if (Date.now() - claimStat.mtimeMs < CLAIM_STALE_MS) {
344
+ return false;
345
+ }
346
+ await rm(claimFile, { force: true });
347
+ } catch {
348
+ return false;
349
+ }
350
+ }
351
+ }
352
+
353
+ return false;
354
+ }
355
+
356
+ async function releaseCrossProcessLock(dedupeKey: string): Promise<void> {
357
+ const claimFile = resolveClaimFilePath(dedupeKey);
358
+ try {
359
+ await rm(claimFile, { force: true });
360
+ } catch (err) {
361
+ void err;
362
+ }
363
+ }
364
+
272
365
  export async function claimMixinInboundMessage(params: ClaimMixinInboundMessageParams): Promise<ClaimMixinInboundMessageResult> {
273
366
  const dedupeKey = buildDedupeScopeKey(params);
274
367
  if (!dedupeKey.trim()) {
@@ -280,6 +373,7 @@ export async function claimMixinInboundMessage(params: ClaimMixinInboundMessageP
280
373
  }
281
374
 
282
375
  await ensureLoaded(params.log);
376
+ await refreshStateFromDisk(params.log);
283
377
  const now = Date.now();
284
378
  if (shouldSweep(now)) {
285
379
  pruneSeenEntries(now);
@@ -312,6 +406,15 @@ export async function claimMixinInboundMessage(params: ClaimMixinInboundMessageP
312
406
  };
313
407
  }
314
408
 
409
+ const claimed = await claimCrossProcessLock(dedupeKey, params.log);
410
+ if (!claimed) {
411
+ return {
412
+ ok: false,
413
+ dedupeKey,
414
+ reason: "duplicate",
415
+ };
416
+ }
417
+
315
418
  state.pending.add(dedupeKey);
316
419
  return {
317
420
  ok: true,
@@ -332,15 +435,17 @@ export async function commitMixinInboundMessage(dedupeKey: string, log?: { warn:
332
435
  state.seen.set(normalizedKey, Date.now());
333
436
  pruneSeenEntries(Date.now());
334
437
  schedulePersist(log);
438
+ await persistState(log);
335
439
  }
336
440
 
337
- export function releaseMixinInboundMessage(dedupeKey: string): void {
441
+ export async function releaseMixinInboundMessage(dedupeKey: string): Promise<void> {
338
442
  const normalizedKey = dedupeKey.trim();
339
443
  if (!normalizedKey) {
340
444
  return;
341
445
  }
342
446
  const state = getState();
343
447
  state.pending.delete(normalizedKey);
448
+ await releaseCrossProcessLock(normalizedKey);
344
449
  }
345
450
 
346
451
  export function buildMixinInboundDedupeKey(params: {