@invago/mixin 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,12 +29,10 @@ export interface MixinInboundMessage {
28
29
  quoteMessageId?: string;
29
30
  publicKey?: string;
30
31
  }
31
-
32
- const processedMessages = new Set<string>();
33
- const MAX_DEDUP_SIZE = 2000;
34
- const unauthNotifiedUsers = new Map<string, number>();
35
- const unauthNotifiedGroups = new Map<string, number>();
36
- 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>();
37
36
  const UNAUTH_NOTIFY_INTERVAL = 20 * 60 * 1000;
38
37
  const MAX_UNAUTH_NOTIFY_USERS = 1000;
39
38
  const MAX_UNAUTH_NOTIFY_GROUPS = 1000;
@@ -46,10 +45,13 @@ const BOT_PROFILE_CACHE_TTL_MS = 10 * 60 * 1000;
46
45
  const SESSION_LABEL_MAX_LENGTH = 64;
47
46
  const requireFromHere = createRequire(import.meta.url);
48
47
 
49
- type CachedUserProfile = {
50
- fullName: string;
51
- expiresAt: number;
52
- };
48
+ type CachedUserProfile = {
49
+ fullName: string;
50
+ identityNumber: string;
51
+ relationship: string;
52
+ senderKind: MixinSenderKind;
53
+ expiresAt: number;
54
+ };
53
55
 
54
56
  type CachedGroupProfile = {
55
57
  name: string;
@@ -68,6 +70,15 @@ type CachedBotIdentity = {
68
70
  expiresAt: number;
69
71
  };
70
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
+
71
82
  type GroupAccessDecision = {
72
83
  allowed: boolean;
73
84
  groupPolicy: "open" | "allowlist" | "disabled";
@@ -161,21 +172,7 @@ function evaluateMixinSenderGroupAccess(params: {
161
172
  };
162
173
  }
163
174
 
164
- function isProcessed(messageId: string): boolean {
165
- return processedMessages.has(messageId);
166
- }
167
-
168
- function markProcessed(messageId: string): void {
169
- if (processedMessages.size >= MAX_DEDUP_SIZE) {
170
- const first = processedMessages.values().next().value;
171
- if (first) {
172
- processedMessages.delete(first);
173
- }
174
- }
175
- processedMessages.add(messageId);
176
- }
177
-
178
- function pruneUnauthNotifiedUsers(now: number): void {
175
+ function pruneUnauthNotifiedUsers(now: number): void {
179
176
  for (const [userId, lastNotified] of unauthNotifiedUsers) {
180
177
  if (now - lastNotified > UNAUTH_NOTIFY_INTERVAL) {
181
178
  unauthNotifiedUsers.delete(userId);
@@ -230,9 +227,47 @@ function buildGroupProfileCacheKey(accountId: string, conversationId: string): s
230
227
  return `${accountId}:${conversationId.trim().toLowerCase()}`;
231
228
  }
232
229
 
233
- function buildBotProfileCacheKey(accountId: string): string {
234
- return accountId.trim().toLowerCase();
235
- }
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
+ }
236
271
 
237
272
  function pruneUserProfileCache(now: number): void {
238
273
  for (const [key, cached] of cachedUserProfiles) {
@@ -361,42 +396,87 @@ async function loadUpdateSessionStore(log: {
361
396
  }
362
397
  }
363
398
 
364
- async function resolveSenderName(params: {
365
- accountId: string;
366
- config: MixinAccountConfig;
367
- userId: string;
368
- log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
369
- }): Promise<string> {
370
- const userId = params.userId.trim();
371
- if (!userId) {
372
- return "";
373
- }
374
-
375
- const now = Date.now();
376
- const cacheKey = buildUserProfileCacheKey(params.accountId, userId);
377
- const cached = cachedUserProfiles.get(cacheKey);
378
- if (cached && cached.expiresAt > now) {
379
- return cached.fullName;
380
- }
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
+ }
381
436
 
382
437
  pruneUserProfileCache(now);
383
438
 
384
- try {
385
- const client = buildClient(params.config);
386
- const user = await client.user.fetch(userId);
387
- const fullName = typeof user.full_name === "string" && user.full_name.trim() ? user.full_name.trim() : userId;
388
- cachedUserProfiles.set(cacheKey, {
389
- fullName,
390
- expiresAt: now + USER_PROFILE_CACHE_TTL_MS,
391
- });
392
- return fullName;
393
- } catch (err) {
394
- params.log.warn(
395
- `[mixin] failed to resolve sender profile: accountId=${params.accountId}, userId=${userId}, error=${err instanceof Error ? err.message : String(err)}`,
396
- );
397
- return userId;
398
- }
399
- }
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
+ }
400
480
 
401
481
  async function resolveGroupName(params: {
402
482
  accountId: string;
@@ -621,6 +701,129 @@ function buildQuotedMessageContextNote(params: {
621
701
  ];
622
702
  }
623
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
+
624
827
  async function resolveInboundAttachment(params: {
625
828
  rt: ReturnType<typeof getMixinRuntime>;
626
829
  config: MixinAccountConfig;
@@ -980,76 +1183,108 @@ function evaluateMixinGroupAccess(params: {
980
1183
  };
981
1184
  }
982
1185
 
983
- export async function handleMixinMessage(params: {
984
- cfg: OpenClawConfig;
985
- accountId: string;
1186
+ export async function handleMixinMessage(params: {
1187
+ cfg: OpenClawConfig;
1188
+ accountId: string;
986
1189
  msg: MixinInboundMessage;
987
1190
  isDirect: boolean;
988
1191
  log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
989
- }): Promise<void> {
990
- const { cfg, accountId, msg, isDirect, log } = params;
991
- const rt = getMixinRuntime();
992
-
993
- if (isProcessed(msg.messageId)) {
994
- return;
995
- }
996
-
997
- const config = getAccountConfig(cfg, accountId);
998
-
999
- if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
1000
- log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
1001
- try {
1002
- const decrypted = decryptMixinMessage(
1003
- msg.data,
1004
- config.sessionPrivateKey!,
1005
- config.sessionId!,
1006
- );
1007
- if (!decrypted) {
1008
- log.error(`[mixin] decryption failed for ${msg.messageId}`);
1009
- markProcessed(msg.messageId);
1010
- return;
1011
- }
1012
- log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
1013
- msg.data = Buffer.from(decrypted).toString("base64");
1014
- msg.category = "PLAIN_TEXT";
1015
- } catch (err) {
1016
- log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
1017
- markProcessed(msg.messageId);
1018
- return;
1019
- }
1020
- }
1021
-
1022
- let isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
1023
- const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
1192
+ }): Promise<void> {
1193
+ const { cfg, accountId, msg, isDirect, log } = params;
1194
+ const rt = getMixinRuntime();
1024
1195
 
1025
- if (!isTextMessage && !isAttachmentMessage) {
1026
- const fallbackText = tryDecodeFallbackText(msg.data);
1027
- if (fallbackText) {
1028
- log.warn(
1029
- `[mixin] treating unexpected category as text: messageId=${msg.messageId}, category=${msg.category}, fallbackLength=${fallbackText.length}`,
1030
- );
1031
- msg.category = "PLAIN_TEXT";
1032
- msg.data = Buffer.from(fallbackText).toString("base64");
1033
- isTextMessage = true;
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}`);
1034
1210
  }
1035
- }
1036
-
1037
- if (!isTextMessage && !isAttachmentMessage) {
1038
- log.info(
1039
- `[mixin] skip non-text message: messageId=${msg.messageId}, category=${msg.category}, quoteMessageId=${msg.quoteMessageId ?? "none"}`,
1040
- );
1041
1211
  return;
1042
1212
  }
1213
+ const dedupeKey = claim.dedupeKey;
1214
+ let committed = false;
1043
1215
 
1044
- const decodedBody = decodeContent(msg.category, msg.data);
1045
- let text = decodedBody.trim();
1046
- let mediaPayload: AgentMediaPayload | undefined;
1047
- if (isAttachmentMessage) {
1048
- const resolved = await resolveInboundAttachment({ rt, config, msg, log });
1049
- text = resolved.text.trim();
1050
- mediaPayload = resolved.mediaPayload;
1051
- }
1052
- log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
1216
+ const commit = async (): Promise<void> => {
1217
+ if (committed) {
1218
+ return;
1219
+ }
1220
+ committed = true;
1221
+ await commitMixinInboundMessage(dedupeKey, log);
1222
+ };
1223
+
1224
+ try {
1225
+ const config = getAccountConfig(cfg, accountId);
1226
+
1227
+ if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
1228
+ log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
1229
+ try {
1230
+ const decrypted = decryptMixinMessage(
1231
+ msg.data,
1232
+ config.sessionPrivateKey!,
1233
+ config.sessionId!,
1234
+ );
1235
+ if (!decrypted) {
1236
+ log.error(`[mixin] decryption failed for ${msg.messageId}`);
1237
+ await commit();
1238
+ return;
1239
+ }
1240
+ log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
1241
+ msg.data = Buffer.from(decrypted).toString("base64");
1242
+ msg.category = "PLAIN_TEXT";
1243
+ } catch (err) {
1244
+ log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
1245
+ await commit();
1246
+ return;
1247
+ }
1248
+ }
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
+
1256
+ let isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
1257
+ const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
1258
+
1259
+ if (!isTextMessage && !isAttachmentMessage) {
1260
+ const fallbackText = tryDecodeFallbackText(msg.data);
1261
+ if (fallbackText) {
1262
+ log.warn(
1263
+ `[mixin] treating unexpected category as text: messageId=${msg.messageId}, category=${msg.category}, fallbackLength=${fallbackText.length}`,
1264
+ );
1265
+ msg.category = "PLAIN_TEXT";
1266
+ msg.data = Buffer.from(fallbackText).toString("base64");
1267
+ isTextMessage = true;
1268
+ }
1269
+ }
1270
+
1271
+ if (!isTextMessage && !isAttachmentMessage) {
1272
+ log.info(
1273
+ `[mixin] skip non-text message: messageId=${msg.messageId}, category=${msg.category}, quoteMessageId=${msg.quoteMessageId ?? "none"}`,
1274
+ );
1275
+ await commit();
1276
+ return;
1277
+ }
1278
+
1279
+ const decodedBody = decodeContent(msg.category, msg.data);
1280
+ let text = decodedBody.trim();
1281
+ let mediaPayload: AgentMediaPayload | undefined;
1282
+ if (isAttachmentMessage) {
1283
+ const resolved = await resolveInboundAttachment({ rt, config, msg, log });
1284
+ text = resolved.text.trim();
1285
+ mediaPayload = resolved.mediaPayload;
1286
+ }
1287
+ log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
1053
1288
 
1054
1289
  const botIdentity = await resolveBotIdentity({
1055
1290
  accountId,
@@ -1082,6 +1317,7 @@ export async function handleMixinMessage(params: {
1082
1317
  }
1083
1318
 
1084
1319
  if (!text) {
1320
+ await commit();
1085
1321
  return;
1086
1322
  }
1087
1323
 
@@ -1094,9 +1330,17 @@ export async function handleMixinMessage(params: {
1094
1330
  botIdentity.userId,
1095
1331
  ].filter((value): value is string => Boolean(value && value.trim()));
1096
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
+ );
1097
1341
  if (!isDirect) {
1098
1342
  log.info(
1099
- `[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}`,
1100
1344
  );
1101
1345
  }
1102
1346
 
@@ -1104,16 +1348,10 @@ export async function handleMixinMessage(params: {
1104
1348
  !isDirect &&
1105
1349
  conversationPolicy &&
1106
1350
  !(isAttachmentMessage && conversationPolicy.mediaBypassMention) &&
1107
- !shouldPassGroupFilter(
1108
- {
1109
- ...config,
1110
- requireMentionInGroup: conversationPolicy.requireMention,
1111
- },
1112
- text,
1113
- botAliases,
1114
- )
1351
+ !shouldPassGroupTrigger
1115
1352
  ) {
1116
1353
  log.info(`[mixin] group message filtered: ${msg.messageId}`);
1354
+ await commit();
1117
1355
  return;
1118
1356
  }
1119
1357
 
@@ -1142,11 +1380,11 @@ export async function handleMixinMessage(params: {
1142
1380
  if (!isDirect && isMixinGroupAuthCommand(text)) {
1143
1381
  const now = Date.now();
1144
1382
  const lastNotified = unauthNotifiedGroups.get(msg.conversationId) ?? 0;
1145
- const shouldNotify = lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL;
1146
- if (!shouldNotify) {
1147
- markProcessed(msg.messageId);
1148
- return;
1149
- }
1383
+ const shouldNotify = lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL;
1384
+ if (!shouldNotify) {
1385
+ await commit();
1386
+ return;
1387
+ }
1150
1388
  pruneUnauthNotifiedGroups(now);
1151
1389
  unauthNotifiedGroups.set(msg.conversationId, now);
1152
1390
  const { code, created } = await rt.channel.pairing.upsertPairingRequest({
@@ -1160,9 +1398,9 @@ export async function handleMixinMessage(params: {
1160
1398
  userId: msg.userId,
1161
1399
  }),
1162
1400
  });
1163
- markProcessed(msg.messageId);
1164
- await sendTextMessage(
1165
- cfg,
1401
+ await commit();
1402
+ await sendTextMessage(
1403
+ cfg,
1166
1404
  accountId,
1167
1405
  msg.conversationId,
1168
1406
  undefined,
@@ -1176,21 +1414,21 @@ export async function handleMixinMessage(params: {
1176
1414
  );
1177
1415
  return;
1178
1416
  }
1179
- if (isDirect) {
1180
- log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
1417
+ if (isDirect) {
1418
+ log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
1181
1419
  } else {
1182
1420
  log.warn(
1183
1421
  `[mixin] group sender ${msg.userId} blocked: conversationId=${msg.conversationId}, groupPolicy=${groupAccess?.groupPolicy ?? "unknown"}, reason=${groupAccess?.reason ?? "unknown"}`,
1184
1422
  );
1185
1423
  }
1186
- markProcessed(msg.messageId);
1187
- if (isDirect) {
1188
- await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
1189
- }
1190
- return;
1191
- }
1192
-
1193
- 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();
1194
1432
 
1195
1433
  if (isOutboxCommand(text)) {
1196
1434
  if (isOutboxPurgeInvalidCommand(text)) {
@@ -1304,10 +1542,10 @@ export async function handleMixinMessage(params: {
1304
1542
 
1305
1543
  log.info(`[mixin] route result: ${route ? "FOUND" : "NULL"} - agentId=${route?.agentId ?? "N/A"}`);
1306
1544
 
1307
- if (!route) {
1308
- log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
1309
- return;
1310
- }
1545
+ if (!route) {
1546
+ log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
1547
+ return;
1548
+ }
1311
1549
 
1312
1550
  const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(text, cfg);
1313
1551
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
@@ -1329,12 +1567,14 @@ export async function handleMixinMessage(params: {
1329
1567
  })
1330
1568
  : undefined;
1331
1569
 
1332
- const senderName = await resolveSenderName({
1570
+ const senderProfile = await resolveSenderProfile({
1333
1571
  accountId,
1334
1572
  config,
1335
1573
  userId: msg.userId,
1574
+ category: msg.category,
1336
1575
  log,
1337
1576
  });
1577
+ const senderName = senderProfile.fullName;
1338
1578
  const groupName = isDirect
1339
1579
  ? ""
1340
1580
  : await resolveGroupName({
@@ -1366,10 +1606,19 @@ export async function handleMixinMessage(params: {
1366
1606
  ReplyToBody: replyContext?.body,
1367
1607
  ReplyToSender: replyContext?.sender,
1368
1608
  ReplyToIsQuote: replyContext ? true : undefined,
1369
- UntrustedContext: replyContext?.id ? buildQuotedMessageContextNote({
1370
- quoteMessageId: replyContext.id,
1371
- found: replyContext.found,
1372
- }) : 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
+ ),
1373
1622
  CommandAuthorized: commandAuthorized,
1374
1623
  OriginatingChannel: "mixin",
1375
1624
  OriginatingTo: isDirect ? msg.userId : msg.conversationId,
@@ -1397,26 +1646,103 @@ export async function handleMixinMessage(params: {
1397
1646
 
1398
1647
  log.info(`[mixin] dispatching ${msg.messageId} from ${msg.userId}`);
1399
1648
 
1400
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1401
- ctx,
1402
- cfg,
1403
- dispatcherOptions: {
1404
- deliver: async (payload) => {
1405
- const replyText = payload.text ?? "";
1406
- if (!replyText) {
1407
- return;
1408
- }
1409
- const recipientId = isDirect ? msg.userId : undefined;
1410
- await deliverMixinReply({
1411
- cfg,
1412
- accountId,
1413
- conversationId: msg.conversationId,
1414
- recipientId,
1415
- creatorId: msg.userId,
1416
- text: replyText,
1417
- log,
1418
- });
1419
- },
1420
- },
1421
- });
1422
- }
1649
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1650
+ ctx,
1651
+ cfg,
1652
+ dispatcherOptions: {
1653
+ deliver: async (payload) => {
1654
+ const replyText = payload.text ?? "";
1655
+ if (!replyText) {
1656
+ return;
1657
+ }
1658
+ const recipientId = isDirect ? msg.userId : undefined;
1659
+ await deliverMixinReply({
1660
+ cfg,
1661
+ accountId,
1662
+ conversationId: msg.conversationId,
1663
+ recipientId,
1664
+ creatorId: msg.userId,
1665
+ text: replyText,
1666
+ log,
1667
+ });
1668
+ },
1669
+ },
1670
+ });
1671
+ } finally {
1672
+ if (!committed) {
1673
+ releaseMixinInboundMessage(dedupeKey);
1674
+ }
1675
+ }
1676
+ }
1677
+
1678
+ export async function handleMixinSystemConversation(params: {
1679
+ cfg: OpenClawConfig;
1680
+ accountId: string;
1681
+ msg: MixinInboundMessage;
1682
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
1683
+ }): Promise<void> {
1684
+ const { cfg, accountId, msg, log } = params;
1685
+ const claim = await claimMixinInboundMessage({
1686
+ accountId,
1687
+ conversationId: msg.conversationId,
1688
+ messageId: msg.messageId,
1689
+ createdAt: msg.createdAt,
1690
+ log,
1691
+ });
1692
+ if (!claim.ok) {
1693
+ if (claim.reason === "duplicate") {
1694
+ log.info(`[mixin] duplicate system conversation suppressed: accountId=${accountId}, messageId=${msg.messageId}`);
1695
+ } else if (claim.reason === "stale") {
1696
+ log.info(
1697
+ `[mixin] stale system conversation dropped: accountId=${accountId}, messageId=${msg.messageId}, createdAt=${msg.createdAt}`,
1698
+ );
1699
+ } else {
1700
+ log.warn(`[mixin] invalid system conversation dedupe key: accountId=${accountId}, messageId=${msg.messageId}`);
1701
+ }
1702
+ return;
1703
+ }
1704
+ const dedupeKey = claim.dedupeKey;
1705
+ let committed = false;
1706
+
1707
+ const commit = async (): Promise<void> => {
1708
+ if (committed) {
1709
+ return;
1710
+ }
1711
+ committed = true;
1712
+ await commitMixinInboundMessage(dedupeKey, log);
1713
+ };
1714
+
1715
+ try {
1716
+ const config = getAccountConfig(cfg, accountId);
1717
+ const text = decodeSystemConversationText(msg.data);
1718
+ const payload = tryParseSystemConversationPayload(text);
1719
+ const joinedUserIds = Array.from(new Set(extractSystemConversationUserIds(payload)))
1720
+ .filter((userId) => userId && !isSystemSenderId(userId) && userId !== config.appId?.trim().toLowerCase());
1721
+ const joinLike = isJoinLikeSystemConversation(text, payload);
1722
+
1723
+ log.info(
1724
+ `[mixin] system conversation: messageId=${msg.messageId}, joinLike=${joinLike}, joinedUserIds=${joinedUserIds.join(",") || "none"}`,
1725
+ );
1726
+
1727
+ await commit();
1728
+
1729
+ if (!joinLike) {
1730
+ return;
1731
+ }
1732
+
1733
+ const joinedProfiles = await Promise.all(
1734
+ joinedUserIds.map((userId) => resolveSenderProfile({
1735
+ accountId,
1736
+ config,
1737
+ userId,
1738
+ log,
1739
+ })),
1740
+ );
1741
+ const welcomeText = formatMixinWelcomeMessage(joinedProfiles.map((profile) => profile.fullName));
1742
+ await sendTextMessage(cfg, accountId, msg.conversationId, undefined, welcomeText, log);
1743
+ } finally {
1744
+ if (!committed) {
1745
+ releaseMixinInboundMessage(dedupeKey);
1746
+ }
1747
+ }
1748
+ }