@invago/mixin 1.0.16 → 1.0.18
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/README.md +10 -9
- package/README.zh-CN.md +11 -10
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/channel.ts +68 -16
- package/src/inbound-handler.ts +513 -187
- package/src/message-dedup.ts +357 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getMixinRuntime } from "./runtime.js";
|
|
5
|
+
|
|
6
|
+
const DEDUP_STORE_VERSION = 1;
|
|
7
|
+
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
|
|
8
|
+
const DEFAULT_MAX_ENTRIES = 5000;
|
|
9
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
10
|
+
const DEFAULT_STALE_MESSAGE_MS = 30 * 60 * 1000;
|
|
11
|
+
const PERSIST_DEBOUNCE_MS = 1000;
|
|
12
|
+
|
|
13
|
+
type MixinInboundDedupStoreEntry = {
|
|
14
|
+
key: string;
|
|
15
|
+
seenAt: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type MixinInboundDedupStore = {
|
|
19
|
+
version: number;
|
|
20
|
+
entries: MixinInboundDedupStoreEntry[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type MixinInboundDedupState = {
|
|
24
|
+
loaded: boolean;
|
|
25
|
+
seen: Map<string, number>;
|
|
26
|
+
pending: Set<string>;
|
|
27
|
+
persistChain: Promise<void>;
|
|
28
|
+
persistTimer: NodeJS.Timeout | null;
|
|
29
|
+
sweepTimer: NodeJS.Timeout | null;
|
|
30
|
+
dirty: boolean;
|
|
31
|
+
lastSweepAt: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type ClaimMixinInboundMessageParams = {
|
|
35
|
+
accountId: string;
|
|
36
|
+
conversationId?: string;
|
|
37
|
+
messageId: string;
|
|
38
|
+
createdAt?: string;
|
|
39
|
+
log?: {
|
|
40
|
+
info: (message: string) => void;
|
|
41
|
+
warn: (message: string) => void;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type ClaimMixinInboundMessageResult =
|
|
46
|
+
| { ok: true; dedupeKey: string }
|
|
47
|
+
| { ok: false; dedupeKey: string; reason: "duplicate" | "stale" | "invalid" };
|
|
48
|
+
|
|
49
|
+
type GlobalWithMixinInboundDedupState = typeof globalThis & {
|
|
50
|
+
__mixinInboundDedupState__?: MixinInboundDedupState;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function createDefaultState(): MixinInboundDedupState {
|
|
54
|
+
return {
|
|
55
|
+
loaded: false,
|
|
56
|
+
seen: new Map<string, number>(),
|
|
57
|
+
pending: new Set<string>(),
|
|
58
|
+
persistChain: Promise.resolve(),
|
|
59
|
+
persistTimer: null,
|
|
60
|
+
sweepTimer: null,
|
|
61
|
+
dirty: false,
|
|
62
|
+
lastSweepAt: 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getState(): MixinInboundDedupState {
|
|
67
|
+
const globalState = globalThis as GlobalWithMixinInboundDedupState;
|
|
68
|
+
if (!globalState.__mixinInboundDedupState__) {
|
|
69
|
+
globalState.__mixinInboundDedupState__ = createDefaultState();
|
|
70
|
+
}
|
|
71
|
+
return globalState.__mixinInboundDedupState__;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveFallbackDedupDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
75
|
+
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
|
76
|
+
if (stateOverride) {
|
|
77
|
+
return path.join(stateOverride, "mixin");
|
|
78
|
+
}
|
|
79
|
+
const openClawHome = env.OPENCLAW_HOME?.trim();
|
|
80
|
+
if (openClawHome) {
|
|
81
|
+
return path.join(openClawHome, ".openclaw", "mixin");
|
|
82
|
+
}
|
|
83
|
+
return path.join(os.homedir(), ".openclaw", "mixin");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveDedupDir(): string {
|
|
87
|
+
try {
|
|
88
|
+
return path.join(getMixinRuntime().state.resolveStateDir(process.env, os.homedir), "mixin");
|
|
89
|
+
} catch {
|
|
90
|
+
return resolveFallbackDedupDir();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveDedupPaths(): {
|
|
95
|
+
dedupDir: string;
|
|
96
|
+
dedupFile: string;
|
|
97
|
+
dedupTmpFile: string;
|
|
98
|
+
} {
|
|
99
|
+
const dedupDir = resolveDedupDir();
|
|
100
|
+
const dedupFile = path.join(dedupDir, "mixin-inbound-dedup.json");
|
|
101
|
+
return {
|
|
102
|
+
dedupDir,
|
|
103
|
+
dedupFile,
|
|
104
|
+
dedupTmpFile: `${dedupFile}.tmp`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeTimestamp(value: unknown): number | null {
|
|
109
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const timestamp = Math.max(0, Math.floor(value));
|
|
113
|
+
return timestamp > 0 ? timestamp : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildDedupeScopeKey(params: {
|
|
117
|
+
accountId: string;
|
|
118
|
+
conversationId?: string;
|
|
119
|
+
messageId: string;
|
|
120
|
+
}): string {
|
|
121
|
+
const accountId = params.accountId.trim().toLowerCase();
|
|
122
|
+
const conversationId = (params.conversationId ?? "").trim().toLowerCase();
|
|
123
|
+
const messageId = params.messageId.trim().toLowerCase();
|
|
124
|
+
return `${accountId}:${conversationId}:${messageId}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseCreatedAt(createdAt?: string): number | null {
|
|
128
|
+
if (!createdAt) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const timestamp = Date.parse(createdAt);
|
|
132
|
+
if (!Number.isFinite(timestamp)) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return timestamp;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pruneSeenEntries(now: number): boolean {
|
|
139
|
+
const state = getState();
|
|
140
|
+
let changed = false;
|
|
141
|
+
const cutoff = now - DEFAULT_TTL_MS;
|
|
142
|
+
|
|
143
|
+
for (const [key, seenAt] of state.seen) {
|
|
144
|
+
if (seenAt <= cutoff) {
|
|
145
|
+
state.seen.delete(key);
|
|
146
|
+
changed = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
while (state.seen.size > DEFAULT_MAX_ENTRIES) {
|
|
151
|
+
const oldestKey = state.seen.keys().next().value;
|
|
152
|
+
if (!oldestKey) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
state.seen.delete(oldestKey);
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (changed) {
|
|
160
|
+
state.dirty = true;
|
|
161
|
+
}
|
|
162
|
+
state.lastSweepAt = now;
|
|
163
|
+
return changed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function ensureSweepTimer(log?: { warn: (message: string) => void }): void {
|
|
167
|
+
const state = getState();
|
|
168
|
+
if (state.sweepTimer) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
state.sweepTimer = setInterval(() => {
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const changed = pruneSeenEntries(now);
|
|
174
|
+
if (changed || state.dirty) {
|
|
175
|
+
void persistState(log);
|
|
176
|
+
}
|
|
177
|
+
}, DEFAULT_SWEEP_INTERVAL_MS);
|
|
178
|
+
state.sweepTimer.unref?.();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function schedulePersist(log?: { warn: (message: string) => void }): void {
|
|
182
|
+
const state = getState();
|
|
183
|
+
state.dirty = true;
|
|
184
|
+
if (state.persistTimer) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
state.persistTimer = setTimeout(() => {
|
|
188
|
+
state.persistTimer = null;
|
|
189
|
+
void persistState(log);
|
|
190
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
191
|
+
state.persistTimer.unref?.();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function persistState(log?: { warn: (message: string) => void }): Promise<void> {
|
|
195
|
+
const state = getState();
|
|
196
|
+
if (!state.loaded || !state.dirty) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
pruneSeenEntries(now);
|
|
202
|
+
const { dedupDir, dedupFile, dedupTmpFile } = resolveDedupPaths();
|
|
203
|
+
const payload: MixinInboundDedupStore = {
|
|
204
|
+
version: DEDUP_STORE_VERSION,
|
|
205
|
+
entries: Array.from(state.seen.entries()).map(([key, seenAt]) => ({ key, seenAt })),
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
state.dirty = false;
|
|
209
|
+
state.persistChain = state.persistChain
|
|
210
|
+
.catch(() => {})
|
|
211
|
+
.then(async () => {
|
|
212
|
+
try {
|
|
213
|
+
await mkdir(dedupDir, { recursive: true });
|
|
214
|
+
await writeFile(dedupTmpFile, JSON.stringify(payload), "utf-8");
|
|
215
|
+
try {
|
|
216
|
+
await rename(dedupTmpFile, dedupFile);
|
|
217
|
+
} catch {
|
|
218
|
+
await writeFile(dedupFile, JSON.stringify(payload), "utf-8");
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
state.dirty = true;
|
|
222
|
+
log?.warn(
|
|
223
|
+
`[mixin] failed to persist inbound dedup store: ${err instanceof Error ? err.message : String(err)}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await state.persistChain;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function ensureLoaded(log?: { warn: (message: string) => void }): Promise<void> {
|
|
232
|
+
const state = getState();
|
|
233
|
+
if (state.loaded) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const { dedupFile } = resolveDedupPaths();
|
|
238
|
+
try {
|
|
239
|
+
const raw = await readFile(dedupFile, "utf-8");
|
|
240
|
+
const parsed = JSON.parse(raw) as Partial<MixinInboundDedupStore>;
|
|
241
|
+
if (parsed.version === DEDUP_STORE_VERSION && Array.isArray(parsed.entries)) {
|
|
242
|
+
for (const entry of parsed.entries) {
|
|
243
|
+
if (!entry || typeof entry.key !== "string") {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const key = entry.key.trim();
|
|
247
|
+
const seenAt = normalizeTimestamp(entry.seenAt);
|
|
248
|
+
if (!key || seenAt === null) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
state.seen.set(key, seenAt);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
|
|
256
|
+
log?.warn(
|
|
257
|
+
`[mixin] failed to load inbound dedup store: ${err instanceof Error ? err.message : String(err)}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
state.loaded = true;
|
|
263
|
+
pruneSeenEntries(Date.now());
|
|
264
|
+
ensureSweepTimer(log);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function shouldSweep(now: number): boolean {
|
|
268
|
+
const state = getState();
|
|
269
|
+
return now - state.lastSweepAt >= DEFAULT_SWEEP_INTERVAL_MS;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function claimMixinInboundMessage(params: ClaimMixinInboundMessageParams): Promise<ClaimMixinInboundMessageResult> {
|
|
273
|
+
const dedupeKey = buildDedupeScopeKey(params);
|
|
274
|
+
if (!dedupeKey.trim()) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
dedupeKey,
|
|
278
|
+
reason: "invalid",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await ensureLoaded(params.log);
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
if (shouldSweep(now)) {
|
|
285
|
+
pruneSeenEntries(now);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const createdAtTs = parseCreatedAt(params.createdAt);
|
|
289
|
+
if (createdAtTs !== null && now - createdAtTs >= DEFAULT_STALE_MESSAGE_MS) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
dedupeKey,
|
|
293
|
+
reason: "stale",
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const state = getState();
|
|
298
|
+
const seenAt = state.seen.get(dedupeKey);
|
|
299
|
+
if (seenAt !== undefined && now - seenAt < DEFAULT_TTL_MS) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
dedupeKey,
|
|
303
|
+
reason: "duplicate",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (state.pending.has(dedupeKey)) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
dedupeKey,
|
|
311
|
+
reason: "duplicate",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
state.pending.add(dedupeKey);
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
dedupeKey,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function commitMixinInboundMessage(dedupeKey: string, log?: { warn: (message: string) => void }): Promise<void> {
|
|
323
|
+
const normalizedKey = dedupeKey.trim();
|
|
324
|
+
if (!normalizedKey) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await ensureLoaded(log);
|
|
329
|
+
const state = getState();
|
|
330
|
+
state.pending.delete(normalizedKey);
|
|
331
|
+
state.seen.delete(normalizedKey);
|
|
332
|
+
state.seen.set(normalizedKey, Date.now());
|
|
333
|
+
pruneSeenEntries(Date.now());
|
|
334
|
+
schedulePersist(log);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function releaseMixinInboundMessage(dedupeKey: string): void {
|
|
338
|
+
const normalizedKey = dedupeKey.trim();
|
|
339
|
+
if (!normalizedKey) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const state = getState();
|
|
343
|
+
state.pending.delete(normalizedKey);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function buildMixinInboundDedupeKey(params: {
|
|
347
|
+
accountId: string;
|
|
348
|
+
conversationId?: string;
|
|
349
|
+
messageId: string;
|
|
350
|
+
}): string {
|
|
351
|
+
return buildDedupeScopeKey(params);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function flushMixinInboundDedup(log?: { warn: (message: string) => void }): Promise<void> {
|
|
355
|
+
await ensureLoaded(log);
|
|
356
|
+
await persistState(log);
|
|
357
|
+
}
|