@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 +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 +426 -120
- package/src/message-dedup.ts +462 -0
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
|
-
-
|
|
329
|
-
-
|
|
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
|
|
332
|
-
- If a non-mention
|
|
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
|
-
-
|
|
321
|
-
-
|
|
321
|
+
- 根据 Mixin 官方确认,群内用户消息只有在显式 `@bot` 时才会投递给机器人。
|
|
322
|
+
- 推荐写法是 `@<identity_number> + 文本`,例如 `@7000103034 你好`。
|
|
322
323
|
- `requireMentionInGroup: false` 只表示关闭插件自身的群消息二次过滤。
|
|
323
|
-
-
|
|
324
|
-
-
|
|
325
|
-
-
|
|
324
|
+
- 它不会让 Mixin 把未 `@` 的群内用户消息投递给 bot。
|
|
325
|
+
- 如果一条未 `@` 的群内用户消息既没有已读,也没有任何入站日志,那不是插件过滤了它,而是 Mixin 根本没有把这条消息送到插件。
|
|
326
|
+
- 目前群内“引用回复”也不应被当成稳定触发方式,因为 Mixin 不一定会把这类事件稳定投递给 bot。
|
|
326
327
|
|
|
327
328
|
示例:
|
|
328
329
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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:
|
|
489
|
-
data:
|
|
540
|
+
category: rawCategory,
|
|
541
|
+
data: rawData,
|
|
490
542
|
createdAt: rawMsg.created_at ?? new Date().toISOString(),
|
|
491
|
-
quoteMessageId
|
|
543
|
+
quoteMessageId,
|
|
492
544
|
};
|
|
493
545
|
|
|
494
546
|
try {
|
package/src/inbound-handler.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
|
33
|
-
const
|
|
34
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
375
|
-
accountId: string;
|
|
376
|
-
config: MixinAccountConfig;
|
|
377
|
-
userId: string;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1198
|
-
if (isDirect) {
|
|
1199
|
-
await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
|
|
1200
|
-
}
|
|
1201
|
-
return;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
-
|
|
1435
|
-
|
|
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
|
+
}
|