@invago/mixin 1.0.16 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -9
- package/README.zh-CN.md +11 -10
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/channel.ts +68 -16
- package/src/inbound-handler.ts +513 -187
- package/src/message-dedup.ts +357 -0
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,12 +29,10 @@ export interface MixinInboundMessage {
|
|
|
28
29
|
quoteMessageId?: string;
|
|
29
30
|
publicKey?: string;
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
365
|
-
accountId: string;
|
|
366
|
-
config: MixinAccountConfig;
|
|
367
|
-
userId: string;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1187
|
-
if (isDirect) {
|
|
1188
|
-
await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
|
|
1189
|
-
}
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
|
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
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
+
}
|