@invago/mixin 1.0.17 → 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/README.md CHANGED
@@ -10,11 +10,12 @@ Connect [Mixin Messenger](https://mixin.one/messenger) to [OpenClaw](https://ope
10
10
 
11
11
  MixinClaw is an OpenClaw channel plugin. It runs in the same process as the OpenClaw Gateway, receives inbound messages from Mixin Blaze WebSocket, and delivers outbound messages over the Mixin HTTP API.
12
12
 
13
- Important:
14
-
15
- - Install the plugin on the same machine where the OpenClaw Gateway runs.
16
- - OpenClaw config files use JSON5, so comments and trailing commas are allowed.
17
- - The proxy configured by this plugin only affects this plugin.
13
+ Important:
14
+
15
+ - Install the plugin on the same machine where the OpenClaw Gateway runs.
16
+ - OpenClaw config files use JSON5, so comments and trailing commas are allowed.
17
+ - The proxy configured by this plugin only affects this plugin.
18
+ - Per confirmed Mixin platform behavior, user messages in group chats are delivered to the bot only when the bot is explicitly mentioned.
18
19
 
19
20
  ## Recommended Install
20
21
 
@@ -325,11 +326,11 @@ Mixin now supports formal group access controls in addition to direct-message `d
325
326
 
326
327
  Important delivery boundary:
327
328
 
328
- - In practice, Mixin group bots reliably receive messages when the bot is explicitly mentioned.
329
- - The most reliable format is `@<identity_number> your message`, for example `@7000103034 hello`.
329
+ - Per confirmed Mixin platform behavior, group messages from users are delivered to the bot only when the bot is explicitly mentioned.
330
+ - Use `@<identity_number> your message`, for example `@7000103034 hello`.
330
331
  - `requireMentionInGroup: false` only disables this plugin's own post-delivery filtering.
331
- - It does not guarantee that Mixin will deliver every non-mention group message to the bot.
332
- - If a non-mention group message produces no read receipt and no inbound log, the message most likely was not delivered to the plugin by Mixin in the first place.
332
+ - It does not cause Mixin to forward non-mention user messages in groups to the bot.
333
+ - If a non-mention user message produces no read receipt and no inbound log, the message was not delivered to the plugin by Mixin in the first place.
333
334
  - Group quote/reply interactions are currently not treated as a reliable bot trigger, because Mixin may not deliver those events to the bot over Blaze consistently.
334
335
 
335
336
  Example:
package/README.zh-CN.md CHANGED
@@ -10,11 +10,12 @@
10
10
 
11
11
  MixinClaw 是一个 OpenClaw 频道插件。它运行在 OpenClaw Gateway 同一进程中,使用 Mixin Blaze WebSocket 接收入站消息,并通过 Mixin HTTP API 发送出站消息。
12
12
 
13
- 重要说明:
14
-
15
- - 插件需要安装在 OpenClaw Gateway 所在的机器上。
16
- - OpenClaw 配置文件使用 JSON5,支持注释和尾逗号。
17
- - 这里配置的代理只作用于这个插件,不影响其他插件。
13
+ 重要说明:
14
+
15
+ - 插件需要安装在 OpenClaw Gateway 所在的机器上。
16
+ - OpenClaw 配置文件使用 JSON5,支持注释和尾逗号。
17
+ - 这里配置的代理只作用于这个插件,不影响其他插件。
18
+ - 根据 Mixin 官方确认,群内用户消息只有在显式 `@` 机器人的情况下才会投递给 bot。
18
19
 
19
20
  ## 推荐安装方式
20
21
 
@@ -317,12 +318,12 @@ OpenClaw 支持通过 `bindings[].match.accountId` 把不同的频道账号直
317
318
 
318
319
  关于群消息投递边界:
319
320
 
320
- - 目前从实际联调看,Mixin 群里稳定可用的触发方式仍然是显式 `@bot`。
321
- - 最稳的写法是 `@<identity_number> + 文本`,例如 `@7000103034 你好`。
321
+ - 根据 Mixin 官方确认,群内用户消息只有在显式 `@bot` 时才会投递给机器人。
322
+ - 推荐写法是 `@<identity_number> + 文本`,例如 `@7000103034 你好`。
322
323
  - `requireMentionInGroup: false` 只表示关闭插件自身的群消息二次过滤。
323
- - 它不能保证 Mixin 平台一定把所有未 `@` 的群消息投递给机器人。
324
- - 如果群里未 `@` 的消息既没有已读,也没有任何入站日志,通常说明这条消息根本没有被 Mixin 投递到插件。
325
- - 目前群内“引用回复”不应被当成稳定触发方式,因为 Mixin 不一定会把这类事件稳定投递给 bot。
324
+ - 它不会让 Mixin 把未 `@` 的群内用户消息投递给 bot。
325
+ - 如果一条未 `@` 的群内用户消息既没有已读,也没有任何入站日志,那不是插件过滤了它,而是 Mixin 根本没有把这条消息送到插件。
326
+ - 目前群内“引用回复”也不应被当成稳定触发方式,因为 Mixin 不一定会把这类事件稳定投递给 bot。
326
327
 
327
328
  示例:
328
329
 
@@ -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.17",
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.17",
3
+ "version": "1.0.19",
4
4
  "description": "Mixin Messenger channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/channel.ts CHANGED
@@ -10,7 +10,7 @@ import { buildClient, sleep } from "./shared.js";
10
10
  import { MixinConfigSchema } from "./config-schema.js";
11
11
  import { describeAccount, isConfigured, listAccountIds, resolveAccount, resolveDefaultAccountId, resolveMediaMaxMb } from "./config.js";
12
12
  import type { MixinAccountConfig } from "./config-schema.js";
13
- import { handleMixinMessage, type MixinInboundMessage } from "./inbound-handler.js";
13
+ import { handleMixinMessage, handleMixinSystemConversation, type MixinInboundMessage } from "./inbound-handler.js";
14
14
  import { getMixpayStatusSnapshot, startMixpayWorker } from "./mixpay-worker.js";
15
15
  import { mixinOnboardingAdapter } from "./onboarding.js";
16
16
  import { buildMixinOutboundPlanFromReplyPayload, executeMixinOutboundPlan } from "./outbound-plan.js";
@@ -459,8 +459,50 @@ export const mixinPlugin = {
459
459
  onSenderReady: (sender) => {
460
460
  setMixinBlazeSender(accountId, sender);
461
461
  },
462
- handler: {
463
- onMessage: async (rawMsg: any) => {
462
+ handler: {
463
+ onConversation: async (rawMsg: any) => {
464
+ if (stopped) {
465
+ return;
466
+ }
467
+ if (!rawMsg || !rawMsg.message_id) {
468
+ return;
469
+ }
470
+
471
+ const isDirect = await resolveIsDirectMessage({
472
+ config,
473
+ conversationId: rawMsg.conversation_id,
474
+ userId: rawMsg.user_id,
475
+ log,
476
+ });
477
+ const quoteMessageId = extractQuoteMessageId(rawMsg);
478
+ const rawCategory = typeof rawMsg.category === "string" ? rawMsg.category : "SYSTEM_CONVERSATION";
479
+ const rawData = typeof rawMsg.data_base64 === "string"
480
+ ? rawMsg.data_base64
481
+ : typeof rawMsg.data === "string"
482
+ ? rawMsg.data
483
+ : "";
484
+
485
+ log.info(
486
+ `[mixin] blaze inbound: messageId=${rawMsg.message_id}, conversationId=${rawMsg.conversation_id ?? ""}, userId=${rawMsg.user_id ?? ""}, category=${rawCategory}, isDirect=${isDirect}, quoteMessageId=${quoteMessageId ?? "none"}, dataLength=${rawData.length}`,
487
+ );
488
+
489
+ const msg: MixinInboundMessage = {
490
+ conversationId: rawMsg.conversation_id ?? "",
491
+ userId: rawMsg.user_id ?? "",
492
+ messageId: rawMsg.message_id,
493
+ category: rawCategory,
494
+ data: rawData,
495
+ createdAt: rawMsg.created_at ?? new Date().toISOString(),
496
+ quoteMessageId,
497
+ };
498
+
499
+ try {
500
+ await handleMixinSystemConversation({ cfg, accountId, msg, log });
501
+ } catch (err) {
502
+ log.error(`error handling system conversation ${msg.messageId}`, err);
503
+ }
504
+ },
505
+ onMessage: async (rawMsg: any) => {
464
506
  if (stopped) {
465
507
  return;
466
508
  }
@@ -471,24 +513,34 @@ export const mixinPlugin = {
471
513
  return;
472
514
  }
473
515
 
474
- const isDirect = await resolveIsDirectMessage({
475
- config,
476
- conversationId: rawMsg.conversation_id,
477
- userId: rawMsg.user_id,
478
- log,
479
- });
480
- log.info(
481
- `[mixin] inbound route context: messageId=${rawMsg.message_id}, conversationId=${rawMsg.conversation_id ?? ""}, userId=${rawMsg.user_id}, isDirect=${isDirect}`,
482
- );
483
-
516
+ const isDirect = await resolveIsDirectMessage({
517
+ config,
518
+ conversationId: rawMsg.conversation_id,
519
+ userId: rawMsg.user_id,
520
+ log,
521
+ });
522
+ const quoteMessageId = extractQuoteMessageId(rawMsg);
523
+ const rawCategory = typeof rawMsg.category === "string" ? rawMsg.category : "PLAIN_TEXT";
524
+ const rawData = typeof rawMsg.data_base64 === "string"
525
+ ? rawMsg.data_base64
526
+ : typeof rawMsg.data === "string"
527
+ ? rawMsg.data
528
+ : "";
529
+ log.info(
530
+ `[mixin] blaze inbound: messageId=${rawMsg.message_id}, conversationId=${rawMsg.conversation_id ?? ""}, userId=${rawMsg.user_id}, category=${rawCategory}, isDirect=${isDirect}, quoteMessageId=${quoteMessageId ?? "none"}, dataLength=${rawData.length}`,
531
+ );
532
+ log.info(
533
+ `[mixin] inbound route context: messageId=${rawMsg.message_id}, conversationId=${rawMsg.conversation_id ?? ""}, userId=${rawMsg.user_id}, isDirect=${isDirect}`,
534
+ );
535
+
484
536
  const msg: MixinInboundMessage = {
485
537
  conversationId: rawMsg.conversation_id ?? "",
486
538
  userId: rawMsg.user_id,
487
539
  messageId: rawMsg.message_id,
488
- category: rawMsg.category ?? "PLAIN_TEXT",
489
- data: rawMsg.data_base64 ?? rawMsg.data ?? "",
540
+ category: rawCategory,
541
+ data: rawData,
490
542
  createdAt: rawMsg.created_at ?? new Date().toISOString(),
491
- quoteMessageId: extractQuoteMessageId(rawMsg),
543
+ quoteMessageId,
492
544
  };
493
545
 
494
546
  try {
@@ -8,11 +8,12 @@ import { getAccountConfig, resolveConversationPolicy } from "./config.js";
8
8
  import type { MixinAccountConfig } from "./config-schema.js";
9
9
  import { decryptMixinMessage } from "./crypto.js";
10
10
  import { getMixpayOrderStatusText, getRecentMixpayOrdersText, refreshMixpayOrderStatus } from "./mixpay-worker.js";
11
- import { buildMixinOutboundPlanFromReplyText, executeMixinOutboundPlan } from "./outbound-plan.js";
12
- import { getMixinRuntime } from "./runtime.js";
13
- import {
14
- getOutboxStatus,
15
- purgePermanentInvalidOutboxEntries,
11
+ import { buildMixinOutboundPlanFromReplyText, executeMixinOutboundPlan } from "./outbound-plan.js";
12
+ import { getMixinRuntime } from "./runtime.js";
13
+ import { claimMixinInboundMessage, commitMixinInboundMessage, releaseMixinInboundMessage } from "./message-dedup.js";
14
+ import {
15
+ getOutboxStatus,
16
+ purgePermanentInvalidOutboxEntries,
16
17
  sendTextMessage,
17
18
  } from "./send-service.js";
18
19
  import { buildClient } from "./shared.js";
@@ -28,13 +29,10 @@ export interface MixinInboundMessage {
28
29
  quoteMessageId?: string;
29
30
  publicKey?: string;
30
31
  }
31
-
32
- const processedMessages = new Set<string>();
33
- const processingMessages = new Set<string>();
34
- const MAX_DEDUP_SIZE = 2000;
35
- const unauthNotifiedUsers = new Map<string, number>();
36
- const unauthNotifiedGroups = new Map<string, number>();
37
- const loggedAllowFromAccounts = new Set<string>();
32
+
33
+ const unauthNotifiedUsers = new Map<string, number>();
34
+ const unauthNotifiedGroups = new Map<string, number>();
35
+ const loggedAllowFromAccounts = new Set<string>();
38
36
  const UNAUTH_NOTIFY_INTERVAL = 20 * 60 * 1000;
39
37
  const MAX_UNAUTH_NOTIFY_USERS = 1000;
40
38
  const MAX_UNAUTH_NOTIFY_GROUPS = 1000;
@@ -47,10 +45,13 @@ const BOT_PROFILE_CACHE_TTL_MS = 10 * 60 * 1000;
47
45
  const SESSION_LABEL_MAX_LENGTH = 64;
48
46
  const requireFromHere = createRequire(import.meta.url);
49
47
 
50
- type CachedUserProfile = {
51
- fullName: string;
52
- expiresAt: number;
53
- };
48
+ type CachedUserProfile = {
49
+ fullName: string;
50
+ identityNumber: string;
51
+ relationship: string;
52
+ senderKind: MixinSenderKind;
53
+ expiresAt: number;
54
+ };
54
55
 
55
56
  type CachedGroupProfile = {
56
57
  name: string;
@@ -69,6 +70,15 @@ type CachedBotIdentity = {
69
70
  expiresAt: number;
70
71
  };
71
72
 
73
+ type MixinSenderKind = "user" | "bot" | "system" | "self" | "unknown";
74
+
75
+ type ResolvedMixinSenderProfile = {
76
+ fullName: string;
77
+ identityNumber: string;
78
+ relationship: string;
79
+ senderKind: MixinSenderKind;
80
+ };
81
+
72
82
  type GroupAccessDecision = {
73
83
  allowed: boolean;
74
84
  groupPolicy: "open" | "allowlist" | "disabled";
@@ -162,30 +172,7 @@ function evaluateMixinSenderGroupAccess(params: {
162
172
  };
163
173
  }
164
174
 
165
- function markProcessing(messageId: string): boolean {
166
- if (processedMessages.has(messageId) || processingMessages.has(messageId)) {
167
- return false;
168
- }
169
- processingMessages.add(messageId);
170
- return true;
171
- }
172
-
173
- function markProcessed(messageId: string): void {
174
- processingMessages.delete(messageId);
175
- if (processedMessages.size >= MAX_DEDUP_SIZE) {
176
- const first = processedMessages.values().next().value;
177
- if (first) {
178
- processedMessages.delete(first);
179
- }
180
- }
181
- processedMessages.add(messageId);
182
- }
183
-
184
- function clearProcessing(messageId: string): void {
185
- processingMessages.delete(messageId);
186
- }
187
-
188
- function pruneUnauthNotifiedUsers(now: number): void {
175
+ function pruneUnauthNotifiedUsers(now: number): void {
189
176
  for (const [userId, lastNotified] of unauthNotifiedUsers) {
190
177
  if (now - lastNotified > UNAUTH_NOTIFY_INTERVAL) {
191
178
  unauthNotifiedUsers.delete(userId);
@@ -240,9 +227,47 @@ function buildGroupProfileCacheKey(accountId: string, conversationId: string): s
240
227
  return `${accountId}:${conversationId.trim().toLowerCase()}`;
241
228
  }
242
229
 
243
- function buildBotProfileCacheKey(accountId: string): string {
244
- return accountId.trim().toLowerCase();
245
- }
230
+ function buildBotProfileCacheKey(accountId: string): string {
231
+ return accountId.trim().toLowerCase();
232
+ }
233
+
234
+ function isSystemConversationCategory(category: string): boolean {
235
+ return category === "SYSTEM_CONVERSATION";
236
+ }
237
+
238
+ function isRecallMessageCategory(category: string): boolean {
239
+ return category.trim().toUpperCase() === "MESSAGE_RECALL";
240
+ }
241
+
242
+ function isSystemSenderId(userId: string): boolean {
243
+ return userId.trim() === "00000000-0000-0000-0000-000000000000";
244
+ }
245
+
246
+ function isMixinBotIdentityNumber(identityNumber: string): boolean {
247
+ return /^700\d{7}$/.test(identityNumber.trim());
248
+ }
249
+
250
+ function classifyMixinSenderKind(params: {
251
+ userId: string;
252
+ identityNumber?: string;
253
+ category?: string;
254
+ selfUserId?: string;
255
+ }): MixinSenderKind {
256
+ if (params.selfUserId && params.userId.trim() === params.selfUserId.trim()) {
257
+ return "self";
258
+ }
259
+ if (isSystemSenderId(params.userId) || isSystemConversationCategory(params.category ?? "")) {
260
+ return "system";
261
+ }
262
+ const normalizedIdentityNumber = normalizePresentationName(params.identityNumber ?? "");
263
+ if (normalizedIdentityNumber && isMixinBotIdentityNumber(normalizedIdentityNumber)) {
264
+ return "bot";
265
+ }
266
+ if (normalizedIdentityNumber) {
267
+ return "user";
268
+ }
269
+ return "unknown";
270
+ }
246
271
 
247
272
  function pruneUserProfileCache(now: number): void {
248
273
  for (const [key, cached] of cachedUserProfiles) {
@@ -371,42 +396,87 @@ async function loadUpdateSessionStore(log: {
371
396
  }
372
397
  }
373
398
 
374
- async function resolveSenderName(params: {
375
- accountId: string;
376
- config: MixinAccountConfig;
377
- userId: string;
378
- log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
379
- }): Promise<string> {
380
- const userId = params.userId.trim();
381
- if (!userId) {
382
- return "";
383
- }
384
-
385
- const now = Date.now();
386
- const cacheKey = buildUserProfileCacheKey(params.accountId, userId);
387
- const cached = cachedUserProfiles.get(cacheKey);
388
- if (cached && cached.expiresAt > now) {
389
- return cached.fullName;
390
- }
399
+ async function resolveSenderProfile(params: {
400
+ accountId: string;
401
+ config: MixinAccountConfig;
402
+ userId: string;
403
+ category?: string;
404
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
405
+ }): Promise<ResolvedMixinSenderProfile> {
406
+ const userId = params.userId.trim();
407
+ if (!userId) {
408
+ return {
409
+ fullName: "",
410
+ identityNumber: "",
411
+ relationship: "",
412
+ senderKind: "unknown",
413
+ };
414
+ }
415
+
416
+ if (isSystemSenderId(userId) || isSystemConversationCategory(params.category ?? "")) {
417
+ return {
418
+ fullName: "system",
419
+ identityNumber: "",
420
+ relationship: "",
421
+ senderKind: "system",
422
+ };
423
+ }
424
+
425
+ const now = Date.now();
426
+ const cacheKey = buildUserProfileCacheKey(params.accountId, userId);
427
+ const cached = cachedUserProfiles.get(cacheKey);
428
+ if (cached && cached.expiresAt > now) {
429
+ return {
430
+ fullName: cached.fullName,
431
+ identityNumber: cached.identityNumber,
432
+ relationship: cached.relationship,
433
+ senderKind: cached.senderKind,
434
+ };
435
+ }
391
436
 
392
437
  pruneUserProfileCache(now);
393
438
 
394
- try {
395
- const client = buildClient(params.config);
396
- const user = await client.user.fetch(userId);
397
- const fullName = typeof user.full_name === "string" && user.full_name.trim() ? user.full_name.trim() : userId;
398
- cachedUserProfiles.set(cacheKey, {
399
- fullName,
400
- expiresAt: now + USER_PROFILE_CACHE_TTL_MS,
401
- });
402
- return fullName;
403
- } catch (err) {
404
- params.log.warn(
405
- `[mixin] failed to resolve sender profile: accountId=${params.accountId}, userId=${userId}, error=${err instanceof Error ? err.message : String(err)}`,
406
- );
407
- return userId;
408
- }
409
- }
439
+ try {
440
+ const client = buildClient(params.config);
441
+ const user = await client.user.fetch(userId);
442
+ const fullName = typeof user.full_name === "string" && user.full_name.trim() ? user.full_name.trim() : userId;
443
+ const identityNumber = typeof user.identity_number === "string" ? user.identity_number.trim() : "";
444
+ const relationship = typeof user.relationship === "string" ? user.relationship.trim() : "";
445
+ const senderKind = classifyMixinSenderKind({
446
+ userId,
447
+ identityNumber,
448
+ category: params.category,
449
+ selfUserId: params.config.appId,
450
+ });
451
+ cachedUserProfiles.set(cacheKey, {
452
+ fullName,
453
+ identityNumber,
454
+ relationship,
455
+ senderKind,
456
+ expiresAt: now + USER_PROFILE_CACHE_TTL_MS,
457
+ });
458
+ return {
459
+ fullName,
460
+ identityNumber,
461
+ relationship,
462
+ senderKind,
463
+ };
464
+ } catch (err) {
465
+ params.log.warn(
466
+ `[mixin] failed to resolve sender profile: accountId=${params.accountId}, userId=${userId}, error=${err instanceof Error ? err.message : String(err)}`,
467
+ );
468
+ return {
469
+ fullName: userId,
470
+ identityNumber: "",
471
+ relationship: "",
472
+ senderKind: classifyMixinSenderKind({
473
+ userId,
474
+ category: params.category,
475
+ selfUserId: params.config.appId,
476
+ }),
477
+ };
478
+ }
479
+ }
410
480
 
411
481
  async function resolveGroupName(params: {
412
482
  accountId: string;
@@ -631,6 +701,129 @@ function buildQuotedMessageContextNote(params: {
631
701
  ];
632
702
  }
633
703
 
704
+ function buildSenderContextNote(params: {
705
+ senderKind: MixinSenderKind;
706
+ senderIdentityNumber?: string;
707
+ senderRelationship?: string;
708
+ }): string[] {
709
+ const lines = [`Sender kind: ${params.senderKind}`];
710
+ if (params.senderIdentityNumber) {
711
+ lines.push(`Sender identity number: ${params.senderIdentityNumber}`);
712
+ }
713
+ if (params.senderRelationship) {
714
+ lines.push(`Sender relationship: ${params.senderRelationship}`);
715
+ }
716
+ return lines;
717
+ }
718
+
719
+ function combineUntrustedContext(...parts: Array<string[] | undefined>): string[] | undefined {
720
+ const combined = parts.flatMap((part) => part ?? []);
721
+ return combined.length > 0 ? combined : undefined;
722
+ }
723
+
724
+ function decodeSystemConversationText(data: string): string {
725
+ try {
726
+ return Buffer.from(data, "base64").toString("utf-8").replace(/^\uFEFF/, "").trim();
727
+ } catch {
728
+ return data.trim();
729
+ }
730
+ }
731
+
732
+ function tryParseSystemConversationPayload(text: string): unknown {
733
+ if (!text) {
734
+ return null;
735
+ }
736
+ try {
737
+ return JSON.parse(text);
738
+ } catch {
739
+ const start = text.indexOf("{");
740
+ const end = text.lastIndexOf("}");
741
+ if (start >= 0 && end > start) {
742
+ try {
743
+ return JSON.parse(text.slice(start, end + 1));
744
+ } catch {
745
+ return null;
746
+ }
747
+ }
748
+ return null;
749
+ }
750
+ }
751
+
752
+ function collectSystemConversationStrings(value: unknown, depth = 0, output: string[] = []): string[] {
753
+ if (depth > 6 || value == null) {
754
+ return output;
755
+ }
756
+ if (typeof value === "string") {
757
+ const trimmed = value.trim();
758
+ if (trimmed) {
759
+ output.push(trimmed);
760
+ }
761
+ return output;
762
+ }
763
+ if (Array.isArray(value)) {
764
+ for (const item of value) {
765
+ collectSystemConversationStrings(item, depth + 1, output);
766
+ }
767
+ return output;
768
+ }
769
+ if (typeof value === "object") {
770
+ for (const nested of Object.values(value as Record<string, unknown>)) {
771
+ collectSystemConversationStrings(nested, depth + 1, output);
772
+ }
773
+ }
774
+ return output;
775
+ }
776
+
777
+ function extractSystemConversationUserIds(value: unknown, depth = 0, output: string[] = []): string[] {
778
+ if (depth > 6 || value == null) {
779
+ return output;
780
+ }
781
+ if (Array.isArray(value)) {
782
+ for (const item of value) {
783
+ extractSystemConversationUserIds(item, depth + 1, output);
784
+ }
785
+ return output;
786
+ }
787
+ if (typeof value !== "object") {
788
+ return output;
789
+ }
790
+
791
+ const record = value as Record<string, unknown>;
792
+ for (const [key, nested] of Object.entries(record)) {
793
+ if (typeof nested === "string") {
794
+ const normalizedKey = key.toLowerCase();
795
+ const trimmed = nested.trim();
796
+ if (
797
+ trimmed &&
798
+ /^[0-9a-f-]{36}$/i.test(trimmed) &&
799
+ (normalizedKey.includes("user") || normalizedKey.includes("participant") || normalizedKey.includes("member"))
800
+ ) {
801
+ output.push(trimmed.toLowerCase());
802
+ }
803
+ }
804
+ extractSystemConversationUserIds(nested, depth + 1, output);
805
+ }
806
+ return output;
807
+ }
808
+
809
+ function isJoinLikeSystemConversation(text: string, payload: unknown): boolean {
810
+ const haystack = [text, ...collectSystemConversationStrings(payload)]
811
+ .join(" ")
812
+ .toLowerCase();
813
+ return /( join|joined|invite|invited|add|added|加入|进群|入群 )/.test(` ${haystack} `);
814
+ }
815
+
816
+ function formatMixinWelcomeMessage(names: string[]): string {
817
+ const uniqueNames = Array.from(new Set(names.map((name) => normalizePresentationName(name)).filter(Boolean)));
818
+ if (uniqueNames.length === 0) {
819
+ return "欢迎新朋友加入群聊。";
820
+ }
821
+ if (uniqueNames.length === 1) {
822
+ return `欢迎 ${uniqueNames[0]} 加入群聊。`;
823
+ }
824
+ return `欢迎 ${uniqueNames.join("、")} 加入群聊。`;
825
+ }
826
+
634
827
  async function resolveInboundAttachment(params: {
635
828
  rt: ReturnType<typeof getMixinRuntime>;
636
829
  config: MixinAccountConfig;
@@ -991,18 +1184,42 @@ function evaluateMixinGroupAccess(params: {
991
1184
  }
992
1185
 
993
1186
  export async function handleMixinMessage(params: {
994
- cfg: OpenClawConfig;
995
- accountId: string;
1187
+ cfg: OpenClawConfig;
1188
+ accountId: string;
996
1189
  msg: MixinInboundMessage;
997
1190
  isDirect: boolean;
998
1191
  log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
999
- }): Promise<void> {
1192
+ }): Promise<void> {
1000
1193
  const { cfg, accountId, msg, isDirect, log } = params;
1001
1194
  const rt = getMixinRuntime();
1002
1195
 
1003
- if (!markProcessing(msg.messageId)) {
1196
+ const claim = await claimMixinInboundMessage({
1197
+ accountId,
1198
+ conversationId: msg.conversationId,
1199
+ messageId: msg.messageId,
1200
+ createdAt: msg.createdAt,
1201
+ log,
1202
+ });
1203
+ if (!claim.ok) {
1204
+ if (claim.reason === "duplicate") {
1205
+ log.info(`[mixin] duplicate inbound suppressed: accountId=${accountId}, messageId=${msg.messageId}`);
1206
+ } else if (claim.reason === "stale") {
1207
+ log.info(`[mixin] stale inbound dropped: accountId=${accountId}, messageId=${msg.messageId}, createdAt=${msg.createdAt}`);
1208
+ } else {
1209
+ log.warn(`[mixin] invalid inbound dedupe key: accountId=${accountId}, messageId=${msg.messageId}`);
1210
+ }
1004
1211
  return;
1005
1212
  }
1213
+ const dedupeKey = claim.dedupeKey;
1214
+ let committed = false;
1215
+
1216
+ const commit = async (): Promise<void> => {
1217
+ if (committed) {
1218
+ return;
1219
+ }
1220
+ committed = true;
1221
+ await commitMixinInboundMessage(dedupeKey, log);
1222
+ };
1006
1223
 
1007
1224
  try {
1008
1225
  const config = getAccountConfig(cfg, accountId);
@@ -1017,7 +1234,7 @@ export async function handleMixinMessage(params: {
1017
1234
  );
1018
1235
  if (!decrypted) {
1019
1236
  log.error(`[mixin] decryption failed for ${msg.messageId}`);
1020
- markProcessed(msg.messageId);
1237
+ await commit();
1021
1238
  return;
1022
1239
  }
1023
1240
  log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
@@ -1025,11 +1242,17 @@ export async function handleMixinMessage(params: {
1025
1242
  msg.category = "PLAIN_TEXT";
1026
1243
  } catch (err) {
1027
1244
  log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
1028
- markProcessed(msg.messageId);
1245
+ await commit();
1029
1246
  return;
1030
1247
  }
1031
1248
  }
1032
1249
 
1250
+ if (isRecallMessageCategory(msg.category)) {
1251
+ log.info(`[mixin] skip recall message: messageId=${msg.messageId}, category=${msg.category}`);
1252
+ await commit();
1253
+ return;
1254
+ }
1255
+
1033
1256
  let isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
1034
1257
  const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
1035
1258
 
@@ -1049,6 +1272,7 @@ export async function handleMixinMessage(params: {
1049
1272
  log.info(
1050
1273
  `[mixin] skip non-text message: messageId=${msg.messageId}, category=${msg.category}, quoteMessageId=${msg.quoteMessageId ?? "none"}`,
1051
1274
  );
1275
+ await commit();
1052
1276
  return;
1053
1277
  }
1054
1278
 
@@ -1093,6 +1317,7 @@ export async function handleMixinMessage(params: {
1093
1317
  }
1094
1318
 
1095
1319
  if (!text) {
1320
+ await commit();
1096
1321
  return;
1097
1322
  }
1098
1323
 
@@ -1105,9 +1330,17 @@ export async function handleMixinMessage(params: {
1105
1330
  botIdentity.userId,
1106
1331
  ].filter((value): value is string => Boolean(value && value.trim()));
1107
1332
  const groupMentioned = !isDirect && botAliases.some((alias) => hasBotMention(text, alias));
1333
+ const shouldPassGroupTrigger = isDirect || !conversationPolicy || shouldPassGroupFilter(
1334
+ {
1335
+ ...config,
1336
+ requireMentionInGroup: conversationPolicy?.requireMention ?? config.requireMentionInGroup,
1337
+ },
1338
+ text,
1339
+ botAliases,
1340
+ );
1108
1341
  if (!isDirect) {
1109
1342
  log.info(
1110
- `[mixin] group trigger check: messageId=${msg.messageId}, botName=${botIdentity.name}, botUserId=${botIdentity.userId}, botIdentityNumber=${botIdentity.identityNumber || "none"}, replyContext=${replyContext?.id ?? "none"}, mentioned=${groupMentioned}`,
1343
+ `[mixin] group trigger check: messageId=${msg.messageId}, botName=${botIdentity.name}, botUserId=${botIdentity.userId}, botIdentityNumber=${botIdentity.identityNumber || "none"}, replyContext=${replyContext?.id ?? "none"}, mentioned=${groupMentioned}, requireMention=${conversationPolicy?.requireMention ?? config.requireMentionInGroup}, willPass=${shouldPassGroupTrigger}`,
1111
1344
  );
1112
1345
  }
1113
1346
 
@@ -1115,16 +1348,10 @@ export async function handleMixinMessage(params: {
1115
1348
  !isDirect &&
1116
1349
  conversationPolicy &&
1117
1350
  !(isAttachmentMessage && conversationPolicy.mediaBypassMention) &&
1118
- !shouldPassGroupFilter(
1119
- {
1120
- ...config,
1121
- requireMentionInGroup: conversationPolicy.requireMention,
1122
- },
1123
- text,
1124
- botAliases,
1125
- )
1351
+ !shouldPassGroupTrigger
1126
1352
  ) {
1127
1353
  log.info(`[mixin] group message filtered: ${msg.messageId}`);
1354
+ await commit();
1128
1355
  return;
1129
1356
  }
1130
1357
 
@@ -1153,11 +1380,11 @@ export async function handleMixinMessage(params: {
1153
1380
  if (!isDirect && isMixinGroupAuthCommand(text)) {
1154
1381
  const now = Date.now();
1155
1382
  const lastNotified = unauthNotifiedGroups.get(msg.conversationId) ?? 0;
1156
- const shouldNotify = lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL;
1157
- if (!shouldNotify) {
1158
- markProcessed(msg.messageId);
1159
- return;
1160
- }
1383
+ const shouldNotify = lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL;
1384
+ if (!shouldNotify) {
1385
+ await commit();
1386
+ return;
1387
+ }
1161
1388
  pruneUnauthNotifiedGroups(now);
1162
1389
  unauthNotifiedGroups.set(msg.conversationId, now);
1163
1390
  const { code, created } = await rt.channel.pairing.upsertPairingRequest({
@@ -1171,9 +1398,9 @@ export async function handleMixinMessage(params: {
1171
1398
  userId: msg.userId,
1172
1399
  }),
1173
1400
  });
1174
- markProcessed(msg.messageId);
1175
- await sendTextMessage(
1176
- cfg,
1401
+ await commit();
1402
+ await sendTextMessage(
1403
+ cfg,
1177
1404
  accountId,
1178
1405
  msg.conversationId,
1179
1406
  undefined,
@@ -1187,21 +1414,21 @@ export async function handleMixinMessage(params: {
1187
1414
  );
1188
1415
  return;
1189
1416
  }
1190
- if (isDirect) {
1191
- log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
1417
+ if (isDirect) {
1418
+ log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
1192
1419
  } else {
1193
1420
  log.warn(
1194
1421
  `[mixin] group sender ${msg.userId} blocked: conversationId=${msg.conversationId}, groupPolicy=${groupAccess?.groupPolicy ?? "unknown"}, reason=${groupAccess?.reason ?? "unknown"}`,
1195
1422
  );
1196
1423
  }
1197
- markProcessed(msg.messageId);
1198
- if (isDirect) {
1199
- await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
1200
- }
1201
- return;
1202
- }
1203
-
1204
- markProcessed(msg.messageId);
1424
+ await commit();
1425
+ if (isDirect) {
1426
+ await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
1427
+ }
1428
+ return;
1429
+ }
1430
+
1431
+ await commit();
1205
1432
 
1206
1433
  if (isOutboxCommand(text)) {
1207
1434
  if (isOutboxPurgeInvalidCommand(text)) {
@@ -1315,10 +1542,10 @@ export async function handleMixinMessage(params: {
1315
1542
 
1316
1543
  log.info(`[mixin] route result: ${route ? "FOUND" : "NULL"} - agentId=${route?.agentId ?? "N/A"}`);
1317
1544
 
1318
- if (!route) {
1319
- log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
1320
- return;
1321
- }
1545
+ if (!route) {
1546
+ log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
1547
+ return;
1548
+ }
1322
1549
 
1323
1550
  const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(text, cfg);
1324
1551
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
@@ -1340,12 +1567,14 @@ export async function handleMixinMessage(params: {
1340
1567
  })
1341
1568
  : undefined;
1342
1569
 
1343
- const senderName = await resolveSenderName({
1570
+ const senderProfile = await resolveSenderProfile({
1344
1571
  accountId,
1345
1572
  config,
1346
1573
  userId: msg.userId,
1574
+ category: msg.category,
1347
1575
  log,
1348
1576
  });
1577
+ const senderName = senderProfile.fullName;
1349
1578
  const groupName = isDirect
1350
1579
  ? ""
1351
1580
  : await resolveGroupName({
@@ -1377,10 +1606,19 @@ export async function handleMixinMessage(params: {
1377
1606
  ReplyToBody: replyContext?.body,
1378
1607
  ReplyToSender: replyContext?.sender,
1379
1608
  ReplyToIsQuote: replyContext ? true : undefined,
1380
- UntrustedContext: replyContext?.id ? buildQuotedMessageContextNote({
1381
- quoteMessageId: replyContext.id,
1382
- found: replyContext.found,
1383
- }) : undefined,
1609
+ SenderKind: senderProfile.senderKind,
1610
+ SenderIdentityNumber: senderProfile.identityNumber || undefined,
1611
+ UntrustedContext: combineUntrustedContext(
1612
+ buildSenderContextNote({
1613
+ senderKind: senderProfile.senderKind,
1614
+ senderIdentityNumber: senderProfile.identityNumber,
1615
+ senderRelationship: senderProfile.relationship,
1616
+ }),
1617
+ replyContext?.id ? buildQuotedMessageContextNote({
1618
+ quoteMessageId: replyContext.id,
1619
+ found: replyContext.found,
1620
+ }) : undefined,
1621
+ ),
1384
1622
  CommandAuthorized: commandAuthorized,
1385
1623
  OriginatingChannel: "mixin",
1386
1624
  OriginatingTo: isDirect ? msg.userId : msg.conversationId,
@@ -1431,8 +1669,76 @@ export async function handleMixinMessage(params: {
1431
1669
  },
1432
1670
  });
1433
1671
  } finally {
1434
- if (!processedMessages.has(msg.messageId)) {
1435
- clearProcessing(msg.messageId);
1672
+ await releaseMixinInboundMessage(dedupeKey);
1673
+ }
1674
+ }
1675
+
1676
+ export async function handleMixinSystemConversation(params: {
1677
+ cfg: OpenClawConfig;
1678
+ accountId: string;
1679
+ msg: MixinInboundMessage;
1680
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
1681
+ }): Promise<void> {
1682
+ const { cfg, accountId, msg, log } = params;
1683
+ const claim = await claimMixinInboundMessage({
1684
+ accountId,
1685
+ conversationId: msg.conversationId,
1686
+ messageId: msg.messageId,
1687
+ createdAt: msg.createdAt,
1688
+ log,
1689
+ });
1690
+ if (!claim.ok) {
1691
+ if (claim.reason === "duplicate") {
1692
+ log.info(`[mixin] duplicate system conversation suppressed: accountId=${accountId}, messageId=${msg.messageId}`);
1693
+ } else if (claim.reason === "stale") {
1694
+ log.info(
1695
+ `[mixin] stale system conversation dropped: accountId=${accountId}, messageId=${msg.messageId}, createdAt=${msg.createdAt}`,
1696
+ );
1697
+ } else {
1698
+ log.warn(`[mixin] invalid system conversation dedupe key: accountId=${accountId}, messageId=${msg.messageId}`);
1436
1699
  }
1700
+ return;
1701
+ }
1702
+ const dedupeKey = claim.dedupeKey;
1703
+ let committed = false;
1704
+
1705
+ const commit = async (): Promise<void> => {
1706
+ if (committed) {
1707
+ return;
1708
+ }
1709
+ committed = true;
1710
+ await commitMixinInboundMessage(dedupeKey, log);
1711
+ };
1712
+
1713
+ try {
1714
+ const config = getAccountConfig(cfg, accountId);
1715
+ const text = decodeSystemConversationText(msg.data);
1716
+ const payload = tryParseSystemConversationPayload(text);
1717
+ const joinedUserIds = Array.from(new Set(extractSystemConversationUserIds(payload)))
1718
+ .filter((userId) => userId && !isSystemSenderId(userId) && userId !== config.appId?.trim().toLowerCase());
1719
+ const joinLike = isJoinLikeSystemConversation(text, payload);
1720
+
1721
+ log.info(
1722
+ `[mixin] system conversation: messageId=${msg.messageId}, joinLike=${joinLike}, joinedUserIds=${joinedUserIds.join(",") || "none"}`,
1723
+ );
1724
+
1725
+ await commit();
1726
+
1727
+ if (!joinLike) {
1728
+ return;
1729
+ }
1730
+
1731
+ const joinedProfiles = await Promise.all(
1732
+ joinedUserIds.map((userId) => resolveSenderProfile({
1733
+ accountId,
1734
+ config,
1735
+ userId,
1736
+ log,
1737
+ })),
1738
+ );
1739
+ const welcomeText = formatMixinWelcomeMessage(joinedProfiles.map((profile) => profile.fullName));
1740
+ await sendTextMessage(cfg, accountId, msg.conversationId, undefined, welcomeText, log);
1741
+ } finally {
1742
+ await releaseMixinInboundMessage(dedupeKey);
1437
1743
  }
1438
1744
  }
@@ -0,0 +1,462 @@
1
+ import crypto from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { getMixinRuntime } from "./runtime.js";
6
+
7
+ const DEDUP_STORE_VERSION = 1;
8
+ const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
9
+ const DEFAULT_MAX_ENTRIES = 5000;
10
+ const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
11
+ const DEFAULT_STALE_MESSAGE_MS = 30 * 60 * 1000;
12
+ const PERSIST_DEBOUNCE_MS = 1000;
13
+ const CLAIM_STALE_MS = 10 * 60 * 1000;
14
+
15
+ type MixinInboundDedupStoreEntry = {
16
+ key: string;
17
+ seenAt: number;
18
+ };
19
+
20
+ type MixinInboundDedupStore = {
21
+ version: number;
22
+ entries: MixinInboundDedupStoreEntry[];
23
+ };
24
+
25
+ type MixinInboundDedupState = {
26
+ loaded: boolean;
27
+ seen: Map<string, number>;
28
+ pending: Set<string>;
29
+ persistChain: Promise<void>;
30
+ persistTimer: NodeJS.Timeout | null;
31
+ sweepTimer: NodeJS.Timeout | null;
32
+ dirty: boolean;
33
+ lastSweepAt: number;
34
+ lastLoadedAt: number;
35
+ };
36
+
37
+ type ClaimMixinInboundMessageParams = {
38
+ accountId: string;
39
+ conversationId?: string;
40
+ messageId: string;
41
+ createdAt?: string;
42
+ log?: {
43
+ info: (message: string) => void;
44
+ warn: (message: string) => void;
45
+ };
46
+ };
47
+
48
+ type ClaimMixinInboundMessageResult =
49
+ | { ok: true; dedupeKey: string }
50
+ | { ok: false; dedupeKey: string; reason: "duplicate" | "stale" | "invalid" };
51
+
52
+ type GlobalWithMixinInboundDedupState = typeof globalThis & {
53
+ __mixinInboundDedupState__?: MixinInboundDedupState;
54
+ };
55
+
56
+ function createDefaultState(): MixinInboundDedupState {
57
+ return {
58
+ loaded: false,
59
+ seen: new Map<string, number>(),
60
+ pending: new Set<string>(),
61
+ persistChain: Promise.resolve(),
62
+ persistTimer: null,
63
+ sweepTimer: null,
64
+ dirty: false,
65
+ lastSweepAt: 0,
66
+ lastLoadedAt: 0,
67
+ };
68
+ }
69
+
70
+ function getState(): MixinInboundDedupState {
71
+ const globalState = globalThis as GlobalWithMixinInboundDedupState;
72
+ if (!globalState.__mixinInboundDedupState__) {
73
+ globalState.__mixinInboundDedupState__ = createDefaultState();
74
+ }
75
+ return globalState.__mixinInboundDedupState__;
76
+ }
77
+
78
+ function resolveFallbackDedupDir(env: NodeJS.ProcessEnv = process.env): string {
79
+ const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
80
+ if (stateOverride) {
81
+ return path.join(stateOverride, "mixin");
82
+ }
83
+ const openClawHome = env.OPENCLAW_HOME?.trim();
84
+ if (openClawHome) {
85
+ return path.join(openClawHome, ".openclaw", "mixin");
86
+ }
87
+ return path.join(os.homedir(), ".openclaw", "mixin");
88
+ }
89
+
90
+ function resolveDedupDir(): string {
91
+ try {
92
+ return path.join(getMixinRuntime().state.resolveStateDir(process.env, os.homedir), "mixin");
93
+ } catch {
94
+ return resolveFallbackDedupDir();
95
+ }
96
+ }
97
+
98
+ function resolveDedupPaths(): {
99
+ dedupDir: string;
100
+ dedupFile: string;
101
+ dedupTmpFile: string;
102
+ claimsDir: string;
103
+ } {
104
+ const dedupDir = resolveDedupDir();
105
+ const dedupFile = path.join(dedupDir, "mixin-inbound-dedup.json");
106
+ return {
107
+ dedupDir,
108
+ dedupFile,
109
+ dedupTmpFile: `${dedupFile}.tmp`,
110
+ claimsDir: path.join(dedupDir, "claims"),
111
+ };
112
+ }
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
+
123
+ function normalizeTimestamp(value: unknown): number | null {
124
+ if (typeof value !== "number" || !Number.isFinite(value)) {
125
+ return null;
126
+ }
127
+ const timestamp = Math.max(0, Math.floor(value));
128
+ return timestamp > 0 ? timestamp : null;
129
+ }
130
+
131
+ function buildDedupeScopeKey(params: {
132
+ accountId: string;
133
+ conversationId?: string;
134
+ messageId: string;
135
+ }): string {
136
+ const accountId = params.accountId.trim().toLowerCase();
137
+ const conversationId = (params.conversationId ?? "").trim().toLowerCase();
138
+ const messageId = params.messageId.trim().toLowerCase();
139
+ return `${accountId}:${conversationId}:${messageId}`;
140
+ }
141
+
142
+ function parseCreatedAt(createdAt?: string): number | null {
143
+ if (!createdAt) {
144
+ return null;
145
+ }
146
+ const timestamp = Date.parse(createdAt);
147
+ if (!Number.isFinite(timestamp)) {
148
+ return null;
149
+ }
150
+ return timestamp;
151
+ }
152
+
153
+ function pruneSeenEntries(now: number): boolean {
154
+ const state = getState();
155
+ let changed = false;
156
+ const cutoff = now - DEFAULT_TTL_MS;
157
+
158
+ for (const [key, seenAt] of state.seen) {
159
+ if (seenAt <= cutoff) {
160
+ state.seen.delete(key);
161
+ changed = true;
162
+ }
163
+ }
164
+
165
+ while (state.seen.size > DEFAULT_MAX_ENTRIES) {
166
+ const oldestKey = state.seen.keys().next().value;
167
+ if (!oldestKey) {
168
+ break;
169
+ }
170
+ state.seen.delete(oldestKey);
171
+ changed = true;
172
+ }
173
+
174
+ if (changed) {
175
+ state.dirty = true;
176
+ }
177
+ state.lastSweepAt = now;
178
+ return changed;
179
+ }
180
+
181
+ function ensureSweepTimer(log?: { warn: (message: string) => void }): void {
182
+ const state = getState();
183
+ if (state.sweepTimer) {
184
+ return;
185
+ }
186
+ state.sweepTimer = setInterval(() => {
187
+ const now = Date.now();
188
+ const changed = pruneSeenEntries(now);
189
+ if (changed || state.dirty) {
190
+ void persistState(log);
191
+ }
192
+ }, DEFAULT_SWEEP_INTERVAL_MS);
193
+ state.sweepTimer.unref?.();
194
+ }
195
+
196
+ function schedulePersist(log?: { warn: (message: string) => void }): void {
197
+ const state = getState();
198
+ state.dirty = true;
199
+ if (state.persistTimer) {
200
+ return;
201
+ }
202
+ state.persistTimer = setTimeout(() => {
203
+ state.persistTimer = null;
204
+ void persistState(log);
205
+ }, PERSIST_DEBOUNCE_MS);
206
+ state.persistTimer.unref?.();
207
+ }
208
+
209
+ async function persistState(log?: { warn: (message: string) => void }): Promise<void> {
210
+ const state = getState();
211
+ if (!state.loaded || !state.dirty) {
212
+ return;
213
+ }
214
+
215
+ const now = Date.now();
216
+ pruneSeenEntries(now);
217
+ const { dedupDir, dedupFile, dedupTmpFile } = resolveDedupPaths();
218
+ const payload: MixinInboundDedupStore = {
219
+ version: DEDUP_STORE_VERSION,
220
+ entries: Array.from(state.seen.entries()).map(([key, seenAt]) => ({ key, seenAt })),
221
+ };
222
+
223
+ state.dirty = false;
224
+ state.persistChain = state.persistChain
225
+ .catch(() => {})
226
+ .then(async () => {
227
+ try {
228
+ await mkdir(dedupDir, { recursive: true });
229
+ await writeFile(dedupTmpFile, JSON.stringify(payload), "utf-8");
230
+ try {
231
+ await rename(dedupTmpFile, dedupFile);
232
+ } catch {
233
+ await writeFile(dedupFile, JSON.stringify(payload), "utf-8");
234
+ }
235
+ } catch (err) {
236
+ state.dirty = true;
237
+ log?.warn(
238
+ `[mixin] failed to persist inbound dedup store: ${err instanceof Error ? err.message : String(err)}`,
239
+ );
240
+ }
241
+ });
242
+
243
+ await state.persistChain;
244
+ }
245
+
246
+ async function loadStateFromDisk(log?: { warn: (message: string) => void }): Promise<void> {
247
+ const state = getState();
248
+ const { dedupFile } = resolveDedupPaths();
249
+ state.seen.clear();
250
+ try {
251
+ const raw = await readFile(dedupFile, "utf-8");
252
+ const parsed = JSON.parse(raw) as Partial<MixinInboundDedupStore>;
253
+ if (parsed.version === DEDUP_STORE_VERSION && Array.isArray(parsed.entries)) {
254
+ for (const entry of parsed.entries) {
255
+ if (!entry || typeof entry.key !== "string") {
256
+ continue;
257
+ }
258
+ const key = entry.key.trim();
259
+ const seenAt = normalizeTimestamp(entry.seenAt);
260
+ if (!key || seenAt === null) {
261
+ continue;
262
+ }
263
+ state.seen.set(key, seenAt);
264
+ }
265
+ }
266
+ } catch (err) {
267
+ if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
268
+ log?.warn(
269
+ `[mixin] failed to load inbound dedup store: ${err instanceof Error ? err.message : String(err)}`,
270
+ );
271
+ }
272
+ }
273
+
274
+ state.loaded = true;
275
+ state.lastLoadedAt = Date.now();
276
+ pruneSeenEntries(Date.now());
277
+ ensureSweepTimer(log);
278
+ }
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
+
311
+ function shouldSweep(now: number): boolean {
312
+ const state = getState();
313
+ return now - state.lastSweepAt >= DEFAULT_SWEEP_INTERVAL_MS;
314
+ }
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
+
365
+ export async function claimMixinInboundMessage(params: ClaimMixinInboundMessageParams): Promise<ClaimMixinInboundMessageResult> {
366
+ const dedupeKey = buildDedupeScopeKey(params);
367
+ if (!dedupeKey.trim()) {
368
+ return {
369
+ ok: false,
370
+ dedupeKey,
371
+ reason: "invalid",
372
+ };
373
+ }
374
+
375
+ await ensureLoaded(params.log);
376
+ await refreshStateFromDisk(params.log);
377
+ const now = Date.now();
378
+ if (shouldSweep(now)) {
379
+ pruneSeenEntries(now);
380
+ }
381
+
382
+ const createdAtTs = parseCreatedAt(params.createdAt);
383
+ if (createdAtTs !== null && now - createdAtTs >= DEFAULT_STALE_MESSAGE_MS) {
384
+ return {
385
+ ok: false,
386
+ dedupeKey,
387
+ reason: "stale",
388
+ };
389
+ }
390
+
391
+ const state = getState();
392
+ const seenAt = state.seen.get(dedupeKey);
393
+ if (seenAt !== undefined && now - seenAt < DEFAULT_TTL_MS) {
394
+ return {
395
+ ok: false,
396
+ dedupeKey,
397
+ reason: "duplicate",
398
+ };
399
+ }
400
+
401
+ if (state.pending.has(dedupeKey)) {
402
+ return {
403
+ ok: false,
404
+ dedupeKey,
405
+ reason: "duplicate",
406
+ };
407
+ }
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
+
418
+ state.pending.add(dedupeKey);
419
+ return {
420
+ ok: true,
421
+ dedupeKey,
422
+ };
423
+ }
424
+
425
+ export async function commitMixinInboundMessage(dedupeKey: string, log?: { warn: (message: string) => void }): Promise<void> {
426
+ const normalizedKey = dedupeKey.trim();
427
+ if (!normalizedKey) {
428
+ return;
429
+ }
430
+
431
+ await ensureLoaded(log);
432
+ const state = getState();
433
+ state.pending.delete(normalizedKey);
434
+ state.seen.delete(normalizedKey);
435
+ state.seen.set(normalizedKey, Date.now());
436
+ pruneSeenEntries(Date.now());
437
+ schedulePersist(log);
438
+ await persistState(log);
439
+ }
440
+
441
+ export async function releaseMixinInboundMessage(dedupeKey: string): Promise<void> {
442
+ const normalizedKey = dedupeKey.trim();
443
+ if (!normalizedKey) {
444
+ return;
445
+ }
446
+ const state = getState();
447
+ state.pending.delete(normalizedKey);
448
+ await releaseCrossProcessLock(normalizedKey);
449
+ }
450
+
451
+ export function buildMixinInboundDedupeKey(params: {
452
+ accountId: string;
453
+ conversationId?: string;
454
+ messageId: string;
455
+ }): string {
456
+ return buildDedupeScopeKey(params);
457
+ }
458
+
459
+ export async function flushMixinInboundDedup(log?: { warn: (message: string) => void }): Promise<void> {
460
+ await ensureLoaded(log);
461
+ await persistState(log);
462
+ }