@invago/mixin 1.0.15 → 1.0.17

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/index.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { OpenClawPluginApi, OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
2
  import { mixinPlugin } from "./src/channel.js";
4
3
  import {
5
4
  buildMixinAccountsText,
@@ -33,7 +32,11 @@ const plugin = {
33
32
  id: "mixin",
34
33
  name: "Mixin Messenger Channel",
35
34
  description: "Mixin Messenger channel via Blaze WebSocket",
36
- configSchema: emptyPluginConfigSchema(),
35
+ configSchema: {
36
+ type: "object",
37
+ additionalProperties: false,
38
+ properties: {},
39
+ },
37
40
  register(api: OpenClawPluginApi): void {
38
41
  setMixinRuntime(api.runtime);
39
42
  api.registerChannel({ plugin: mixinPlugin });
@@ -2,7 +2,7 @@
2
2
  "id": "mixin",
3
3
  "name": "Mixin Messenger Channel",
4
4
  "description": "Mixin Messenger channel via Blaze WebSocket",
5
- "version": "1.0.15",
5
+ "version": "1.0.17",
6
6
  "channels": [
7
7
  "mixin"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invago/mixin",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Mixin Messenger channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/channel.ts CHANGED
@@ -3,8 +3,6 @@ import { promisify } from "node:util";
3
3
  import { uniqueConversationID } from "@mixin.dev/mixin-node-sdk";
4
4
  import {
5
5
  buildChannelConfigSchema,
6
- formatPairingApproveHint,
7
- resolveChannelMediaMaxBytes,
8
6
  } from "openclaw/plugin-sdk";
9
7
  import type { ChannelGatewayContext, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
10
8
  import { runBlazeLoop } from "./blaze-service.js";
@@ -23,9 +21,10 @@ import { buildMixinAccountSnapshot, buildMixinChannelSummary, resolveMixinStatus
23
21
  type ResolvedMixinAccount = ReturnType<typeof resolveAccount>;
24
22
 
25
23
  const BASE_DELAY = 1000;
26
- const MAX_DELAY = 3000;
27
- const MULTIPLIER = 1.5;
28
- const MEDIA_MAX_BYTES = 30 * 1024 * 1024;
24
+ const MAX_DELAY = 3000;
25
+ const MULTIPLIER = 1.5;
26
+ const MEDIA_MAX_BYTES = 30 * 1024 * 1024;
27
+ const MB = 1024 * 1024;
29
28
  const execFileAsync = promisify(execFile);
30
29
  const CONVERSATION_CATEGORY_CACHE_TTL_MS = 5 * 60 * 1000;
31
30
 
@@ -44,6 +43,10 @@ function createDefaultMixinRuntimeState(accountId: string): {
44
43
  lastError: null,
45
44
  };
46
45
  }
46
+
47
+ function formatMixinPairingApproveHint(channelId: string): string {
48
+ return `Approve via: \`openclaw pairing list ${channelId}\` / \`openclaw pairing approve ${channelId} <code>\``;
49
+ }
47
50
 
48
51
  const conversationCategoryCache = new Map<string, {
49
52
  category: "CONTACT" | "GROUP";
@@ -172,13 +175,16 @@ async function resolveAudioDurationSeconds(filePath: string): Promise<number | n
172
175
  }
173
176
  }
174
177
 
175
- function resolveMixinMediaMaxBytes(cfg: OpenClawConfig, accountId?: string | null): number {
176
- return resolveChannelMediaMaxBytes({
177
- cfg,
178
- resolveChannelLimitMb: ({ cfg, accountId }) => resolveMediaMaxMb(cfg, accountId),
179
- accountId,
180
- }) ?? MEDIA_MAX_BYTES;
181
- }
178
+ function resolveMixinMediaMaxBytes(cfg: OpenClawConfig, accountId?: string | null): number {
179
+ const channelLimitMb = resolveMediaMaxMb(cfg, accountId ?? undefined);
180
+ if (channelLimitMb) {
181
+ return channelLimitMb * MB;
182
+ }
183
+ if (cfg.agents?.defaults?.mediaMaxMb) {
184
+ return cfg.agents.defaults.mediaMaxMb * MB;
185
+ }
186
+ return MEDIA_MAX_BYTES;
187
+ }
182
188
 
183
189
  async function deliverOutboundMixinPayload(params: {
184
190
  cfg: OpenClawConfig;
@@ -344,12 +350,12 @@ export const mixinPlugin = {
344
350
  policy,
345
351
  allowFrom,
346
352
  policyPath: `channels.mixin${basePath}.dmPolicy`,
347
- allowFromPath: `channels.mixin${basePath}.allowFrom`,
348
- approveHint: policy === "pairing"
349
- ? formatPairingApproveHint("mixin")
350
- : allowFrom.length > 0
351
- ? `宸查厤缃櫧鍚嶅崟鐢ㄦ埛鏁?${allowFrom.length}锛屽皢鐢ㄦ埛鐨?Mixin UUID 娣诲姞鍒?allowFrom 鍒楄〃鍗冲彲鎺堟潈`
352
- : "灏嗙敤鎴风殑 Mixin UUID 娣诲姞鍒?allowFrom 鍒楄〃鍗冲彲鎺堟潈",
353
+ allowFromPath: `channels.mixin${basePath}.allowFrom`,
354
+ approveHint: policy === "pairing"
355
+ ? formatMixinPairingApproveHint("mixin")
356
+ : allowFrom.length > 0
357
+ ? `宸查厤缃櫧鍚嶅崟鐢ㄦ埛鏁?${allowFrom.length}锛屽皢鐢ㄦ埛鐨?Mixin UUID 娣诲姞鍒?allowFrom 鍒楄〃鍗冲彲鎺堟潈`
358
+ : "灏嗙敤鎴风殑 Mixin UUID 娣诲姞鍒?allowFrom 鍒楄〃鍗冲彲鎺堟潈",
353
359
  };
354
360
  },
355
361
  },
@@ -1,10 +1,9 @@
1
- import fs from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { createRequire } from "node:module";
5
- import { pathToFileURL } from "node:url";
6
- import { buildAgentMediaPayload, evaluateSenderGroupAccess, resolveDefaultGroupPolicy } from "openclaw/plugin-sdk";
7
- import type { AgentMediaPayload, OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { pathToFileURL } from "node:url";
6
+ import type { AgentMediaPayload, OpenClawConfig } from "openclaw/plugin-sdk";
8
7
  import { getAccountConfig, resolveConversationPolicy } from "./config.js";
9
8
  import type { MixinAccountConfig } from "./config-schema.js";
10
9
  import { decryptMixinMessage } from "./crypto.js";
@@ -30,8 +29,9 @@ export interface MixinInboundMessage {
30
29
  publicKey?: string;
31
30
  }
32
31
 
33
- const processedMessages = new Set<string>();
34
- const MAX_DEDUP_SIZE = 2000;
32
+ const processedMessages = new Set<string>();
33
+ const processingMessages = new Set<string>();
34
+ const MAX_DEDUP_SIZE = 2000;
35
35
  const unauthNotifiedUsers = new Map<string, number>();
36
36
  const unauthNotifiedGroups = new Map<string, number>();
37
37
  const loggedAllowFromAccounts = new Set<string>();
@@ -69,6 +69,13 @@ type CachedBotIdentity = {
69
69
  expiresAt: number;
70
70
  };
71
71
 
72
+ type GroupAccessDecision = {
73
+ allowed: boolean;
74
+ groupPolicy: "open" | "allowlist" | "disabled";
75
+ providerMissingFallbackApplied: boolean;
76
+ reason: "allowed" | "disabled" | "empty_allowlist" | "sender_not_allowlisted";
77
+ };
78
+
72
79
  type MixinAttachmentRequest = {
73
80
  attachmentId: string;
74
81
  mimeType?: string;
@@ -84,21 +91,99 @@ const cachedBotIdentities = new Map<string, CachedBotIdentity>();
84
91
  let cachedUpdateSessionStore:
85
92
  | ((storePath: string, mutator: (store: Record<string, Record<string, unknown>>) => void | Promise<void>) => Promise<unknown>)
86
93
  | null
87
- | undefined;
88
-
89
- function isProcessed(messageId: string): boolean {
90
- return processedMessages.has(messageId);
91
- }
94
+ | undefined;
95
+
96
+ function buildMixinAgentMediaPayload(mediaList: Array<{ path: string; contentType?: string }>): AgentMediaPayload {
97
+ const first = mediaList[0];
98
+ const mediaPaths = mediaList.map((media) => media.path);
99
+ const mediaTypes = mediaList.map((media) => media.contentType).filter((value): value is string => Boolean(value));
100
+ return {
101
+ MediaPath: first?.path,
102
+ MediaType: first?.contentType ?? undefined,
103
+ MediaUrl: first?.path,
104
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
105
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
106
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
107
+ };
108
+ }
109
+
110
+ function resolveMixinDefaultGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" | undefined {
111
+ const groupPolicy = cfg.channels?.defaults?.groupPolicy;
112
+ return groupPolicy === "open" || groupPolicy === "allowlist" || groupPolicy === "disabled"
113
+ ? groupPolicy
114
+ : undefined;
115
+ }
116
+
117
+ function evaluateMixinSenderGroupAccess(params: {
118
+ providerConfigPresent: boolean;
119
+ configuredGroupPolicy?: "open" | "allowlist" | "disabled";
120
+ defaultGroupPolicy?: "open" | "allowlist" | "disabled";
121
+ groupAllowFrom: string[];
122
+ senderId: string;
123
+ isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean;
124
+ }): GroupAccessDecision {
125
+ const configuredPolicy = params.configuredGroupPolicy ?? params.defaultGroupPolicy;
126
+ const providerMissingFallbackApplied = !params.providerConfigPresent && !configuredPolicy;
127
+ const groupPolicy = configuredPolicy ?? (params.providerConfigPresent ? "open" : "allowlist");
128
+
129
+ if (groupPolicy === "disabled") {
130
+ return {
131
+ allowed: false,
132
+ groupPolicy,
133
+ providerMissingFallbackApplied,
134
+ reason: "disabled",
135
+ };
136
+ }
137
+
138
+ if (groupPolicy === "allowlist") {
139
+ if (params.groupAllowFrom.length === 0) {
140
+ return {
141
+ allowed: false,
142
+ groupPolicy,
143
+ providerMissingFallbackApplied,
144
+ reason: "empty_allowlist",
145
+ };
146
+ }
147
+ if (!params.isSenderAllowed(params.senderId, params.groupAllowFrom)) {
148
+ return {
149
+ allowed: false,
150
+ groupPolicy,
151
+ providerMissingFallbackApplied,
152
+ reason: "sender_not_allowlisted",
153
+ };
154
+ }
155
+ }
156
+
157
+ return {
158
+ allowed: true,
159
+ groupPolicy,
160
+ providerMissingFallbackApplied,
161
+ reason: "allowed",
162
+ };
163
+ }
92
164
 
93
- function markProcessed(messageId: string): void {
94
- if (processedMessages.size >= MAX_DEDUP_SIZE) {
95
- const first = processedMessages.values().next().value;
96
- if (first) {
97
- processedMessages.delete(first);
165
+ function markProcessing(messageId: string): boolean {
166
+ if (processedMessages.has(messageId) || processingMessages.has(messageId)) {
167
+ return false;
168
+ }
169
+ processingMessages.add(messageId);
170
+ return true;
171
+ }
172
+
173
+ function markProcessed(messageId: string): void {
174
+ processingMessages.delete(messageId);
175
+ if (processedMessages.size >= MAX_DEDUP_SIZE) {
176
+ const first = processedMessages.values().next().value;
177
+ if (first) {
178
+ processedMessages.delete(first);
98
179
  }
99
180
  }
100
- processedMessages.add(messageId);
101
- }
181
+ processedMessages.add(messageId);
182
+ }
183
+
184
+ function clearProcessing(messageId: string): void {
185
+ processingMessages.delete(messageId);
186
+ }
102
187
 
103
188
  function pruneUnauthNotifiedUsers(now: number): void {
104
189
  for (const [userId, lastNotified] of unauthNotifiedUsers) {
@@ -577,11 +662,11 @@ async function resolveInboundAttachment(params: {
577
662
  );
578
663
 
579
664
  return {
580
- text: formatInboundAttachmentText(params.msg.category, payload),
581
- mediaPayload: buildAgentMediaPayload([
582
- {
583
- path: saved.path,
584
- contentType: saved.contentType ?? payload.mimeType ?? fetched.contentType,
665
+ text: formatInboundAttachmentText(params.msg.category, payload),
666
+ mediaPayload: buildMixinAgentMediaPayload([
667
+ {
668
+ path: saved.path,
669
+ contentType: saved.contentType ?? payload.mimeType ?? fetched.contentType,
585
670
  },
586
671
  ]),
587
672
  };
@@ -887,14 +972,14 @@ function evaluateMixinGroupAccess(params: {
887
972
  };
888
973
  }
889
974
 
890
- const normalizedGroupAllowFrom = normalizeAllowEntries(conversationPolicy.groupAllowFrom);
891
- const decision = evaluateSenderGroupAccess({
892
- providerConfigPresent: true,
893
- configuredGroupPolicy: conversationPolicy.groupPolicy,
894
- defaultGroupPolicy: resolveDefaultGroupPolicy(params.cfg),
895
- groupAllowFrom: normalizedGroupAllowFrom,
896
- senderId: normalizeAllowEntry(params.senderId),
897
- isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(normalizeAllowEntry(senderId)),
975
+ const normalizedGroupAllowFrom = normalizeAllowEntries(conversationPolicy.groupAllowFrom);
976
+ const decision = evaluateMixinSenderGroupAccess({
977
+ providerConfigPresent: true,
978
+ configuredGroupPolicy: conversationPolicy.groupPolicy,
979
+ defaultGroupPolicy: resolveMixinDefaultGroupPolicy(params.cfg),
980
+ groupAllowFrom: normalizedGroupAllowFrom,
981
+ senderId: normalizeAllowEntry(params.senderId),
982
+ isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(normalizeAllowEntry(senderId)),
898
983
  });
899
984
 
900
985
  return {
@@ -905,76 +990,77 @@ function evaluateMixinGroupAccess(params: {
905
990
  };
906
991
  }
907
992
 
908
- export async function handleMixinMessage(params: {
993
+ export async function handleMixinMessage(params: {
909
994
  cfg: OpenClawConfig;
910
995
  accountId: string;
911
996
  msg: MixinInboundMessage;
912
997
  isDirect: boolean;
913
998
  log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
914
999
  }): Promise<void> {
915
- const { cfg, accountId, msg, isDirect, log } = params;
916
- const rt = getMixinRuntime();
917
-
918
- if (isProcessed(msg.messageId)) {
919
- return;
920
- }
921
-
922
- const config = getAccountConfig(cfg, accountId);
923
-
924
- if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
925
- log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
926
- try {
927
- const decrypted = decryptMixinMessage(
928
- msg.data,
929
- config.sessionPrivateKey!,
930
- config.sessionId!,
931
- );
932
- if (!decrypted) {
933
- log.error(`[mixin] decryption failed for ${msg.messageId}`);
934
- markProcessed(msg.messageId);
935
- return;
936
- }
937
- log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
938
- msg.data = Buffer.from(decrypted).toString("base64");
939
- msg.category = "PLAIN_TEXT";
940
- } catch (err) {
941
- log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
942
- markProcessed(msg.messageId);
943
- return;
944
- }
945
- }
946
-
947
- let isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
948
- const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
1000
+ const { cfg, accountId, msg, isDirect, log } = params;
1001
+ const rt = getMixinRuntime();
949
1002
 
950
- if (!isTextMessage && !isAttachmentMessage) {
951
- const fallbackText = tryDecodeFallbackText(msg.data);
952
- if (fallbackText) {
953
- log.warn(
954
- `[mixin] treating unexpected category as text: messageId=${msg.messageId}, category=${msg.category}, fallbackLength=${fallbackText.length}`,
955
- );
956
- msg.category = "PLAIN_TEXT";
957
- msg.data = Buffer.from(fallbackText).toString("base64");
958
- isTextMessage = true;
959
- }
960
- }
961
-
962
- if (!isTextMessage && !isAttachmentMessage) {
963
- log.info(
964
- `[mixin] skip non-text message: messageId=${msg.messageId}, category=${msg.category}, quoteMessageId=${msg.quoteMessageId ?? "none"}`,
965
- );
1003
+ if (!markProcessing(msg.messageId)) {
966
1004
  return;
967
1005
  }
968
1006
 
969
- const decodedBody = decodeContent(msg.category, msg.data);
970
- let text = decodedBody.trim();
971
- let mediaPayload: AgentMediaPayload | undefined;
972
- if (isAttachmentMessage) {
973
- const resolved = await resolveInboundAttachment({ rt, config, msg, log });
974
- text = resolved.text.trim();
975
- mediaPayload = resolved.mediaPayload;
976
- }
977
- log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
1007
+ try {
1008
+ const config = getAccountConfig(cfg, accountId);
1009
+
1010
+ if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
1011
+ log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
1012
+ try {
1013
+ const decrypted = decryptMixinMessage(
1014
+ msg.data,
1015
+ config.sessionPrivateKey!,
1016
+ config.sessionId!,
1017
+ );
1018
+ if (!decrypted) {
1019
+ log.error(`[mixin] decryption failed for ${msg.messageId}`);
1020
+ markProcessed(msg.messageId);
1021
+ return;
1022
+ }
1023
+ log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
1024
+ msg.data = Buffer.from(decrypted).toString("base64");
1025
+ msg.category = "PLAIN_TEXT";
1026
+ } catch (err) {
1027
+ log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
1028
+ markProcessed(msg.messageId);
1029
+ return;
1030
+ }
1031
+ }
1032
+
1033
+ let isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
1034
+ const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
1035
+
1036
+ if (!isTextMessage && !isAttachmentMessage) {
1037
+ const fallbackText = tryDecodeFallbackText(msg.data);
1038
+ if (fallbackText) {
1039
+ log.warn(
1040
+ `[mixin] treating unexpected category as text: messageId=${msg.messageId}, category=${msg.category}, fallbackLength=${fallbackText.length}`,
1041
+ );
1042
+ msg.category = "PLAIN_TEXT";
1043
+ msg.data = Buffer.from(fallbackText).toString("base64");
1044
+ isTextMessage = true;
1045
+ }
1046
+ }
1047
+
1048
+ if (!isTextMessage && !isAttachmentMessage) {
1049
+ log.info(
1050
+ `[mixin] skip non-text message: messageId=${msg.messageId}, category=${msg.category}, quoteMessageId=${msg.quoteMessageId ?? "none"}`,
1051
+ );
1052
+ return;
1053
+ }
1054
+
1055
+ const decodedBody = decodeContent(msg.category, msg.data);
1056
+ let text = decodedBody.trim();
1057
+ let mediaPayload: AgentMediaPayload | undefined;
1058
+ if (isAttachmentMessage) {
1059
+ const resolved = await resolveInboundAttachment({ rt, config, msg, log });
1060
+ text = resolved.text.trim();
1061
+ mediaPayload = resolved.mediaPayload;
1062
+ }
1063
+ log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
978
1064
 
979
1065
  const botIdentity = await resolveBotIdentity({
980
1066
  accountId,
@@ -1322,26 +1408,31 @@ export async function handleMixinMessage(params: {
1322
1408
 
1323
1409
  log.info(`[mixin] dispatching ${msg.messageId} from ${msg.userId}`);
1324
1410
 
1325
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1326
- ctx,
1327
- cfg,
1328
- dispatcherOptions: {
1329
- deliver: async (payload) => {
1330
- const replyText = payload.text ?? "";
1331
- if (!replyText) {
1332
- return;
1333
- }
1334
- const recipientId = isDirect ? msg.userId : undefined;
1335
- await deliverMixinReply({
1336
- cfg,
1337
- accountId,
1338
- conversationId: msg.conversationId,
1339
- recipientId,
1340
- creatorId: msg.userId,
1341
- text: replyText,
1342
- log,
1343
- });
1344
- },
1345
- },
1346
- });
1347
- }
1411
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1412
+ ctx,
1413
+ cfg,
1414
+ dispatcherOptions: {
1415
+ deliver: async (payload) => {
1416
+ const replyText = payload.text ?? "";
1417
+ if (!replyText) {
1418
+ return;
1419
+ }
1420
+ const recipientId = isDirect ? msg.userId : undefined;
1421
+ await deliverMixinReply({
1422
+ cfg,
1423
+ accountId,
1424
+ conversationId: msg.conversationId,
1425
+ recipientId,
1426
+ creatorId: msg.userId,
1427
+ text: replyText,
1428
+ log,
1429
+ });
1430
+ },
1431
+ },
1432
+ });
1433
+ } finally {
1434
+ if (!processedMessages.has(msg.messageId)) {
1435
+ clearProcessing(msg.messageId);
1436
+ }
1437
+ }
1438
+ }