@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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/inbound-handler.ts +2 -6
- package/src/message-dedup.ts +112 -7
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/inbound-handler.ts
CHANGED
|
@@ -1669,9 +1669,7 @@ export async function handleMixinMessage(params: {
|
|
|
1669
1669
|
},
|
|
1670
1670
|
});
|
|
1671
1671
|
} finally {
|
|
1672
|
-
|
|
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
|
-
|
|
1745
|
-
releaseMixinInboundMessage(dedupeKey);
|
|
1746
|
-
}
|
|
1742
|
+
await releaseMixinInboundMessage(dedupeKey);
|
|
1747
1743
|
}
|
|
1748
1744
|
}
|
package/src/message-dedup.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
|
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: {
|