@invago/mixin 1.0.8 → 1.0.10

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.
@@ -1,576 +1,1205 @@
1
- import fs from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { MixinApi } from "@mixin.dev/mixin-node-sdk";
5
- import { buildAgentMediaPayload } from "openclaw/plugin-sdk";
6
- import type { AgentMediaPayload, OpenClawConfig } from "openclaw/plugin-sdk";
7
- import { getAccountConfig } from "./config.js";
8
- import type { MixinAccountConfig } from "./config-schema.js";
9
- import { decryptMixinMessage } from "./crypto.js";
10
- import { buildRequestConfig } from "./proxy.js";
11
- import { buildMixinReplyPlan } from "./reply-format.js";
12
- import { getMixinRuntime } from "./runtime.js";
13
- import {
14
- sendAudioMessage,
15
- getOutboxStatus,
16
- purgePermanentInvalidOutboxEntries,
17
- sendFileMessage,
18
- sendButtonGroupMessage,
19
- sendCardMessage,
20
- sendPostMessage,
21
- sendTextMessage,
22
- } from "./send-service.js";
23
-
24
- export interface MixinInboundMessage {
25
- conversationId: string;
26
- userId: string;
27
- messageId: string;
28
- category: string;
29
- data: string;
30
- createdAt: string;
31
- publicKey?: string;
32
- }
33
-
34
- const processedMessages = new Set<string>();
35
- const MAX_DEDUP_SIZE = 2000;
36
- const unauthNotifiedUsers = new Map<string, number>();
37
- const loggedAllowFromAccounts = new Set<string>();
38
- const UNAUTH_NOTIFY_INTERVAL = 20 * 60 * 1000;
39
- const MAX_UNAUTH_NOTIFY_USERS = 1000;
40
- const INBOUND_MEDIA_MAX_BYTES = 30 * 1024 * 1024;
41
-
42
- type MixinAttachmentRequest = {
43
- attachmentId: string;
44
- mimeType?: string;
45
- size?: number;
46
- fileName?: string;
47
- duration?: number;
48
- };
49
-
50
- function isProcessed(messageId: string): boolean {
51
- return processedMessages.has(messageId);
52
- }
53
-
54
- function markProcessed(messageId: string): void {
55
- if (processedMessages.size >= MAX_DEDUP_SIZE) {
56
- const first = processedMessages.values().next().value;
57
- if (first) {
58
- processedMessages.delete(first);
59
- }
60
- }
61
- processedMessages.add(messageId);
62
- }
63
-
64
- function pruneUnauthNotifiedUsers(now: number): void {
65
- for (const [userId, lastNotified] of unauthNotifiedUsers) {
66
- if (now - lastNotified > UNAUTH_NOTIFY_INTERVAL) {
67
- unauthNotifiedUsers.delete(userId);
68
- }
69
- }
70
-
71
- while (unauthNotifiedUsers.size >= MAX_UNAUTH_NOTIFY_USERS) {
72
- const first = unauthNotifiedUsers.keys().next().value;
73
- if (!first) {
74
- break;
75
- }
76
- unauthNotifiedUsers.delete(first);
77
- }
78
- }
79
-
80
- function decodeContent(category: string, data: string): string {
81
- if (category.startsWith("PLAIN_TEXT") || category.startsWith("PLAIN_POST")) {
82
- try {
83
- return Buffer.from(data, "base64").toString("utf-8");
84
- } catch {
85
- return data;
86
- }
87
- }
88
- return `[${category}]`;
89
- }
90
-
91
- function buildClient(config: MixinAccountConfig) {
92
- return MixinApi({
93
- keystore: {
94
- app_id: config.appId!,
95
- session_id: config.sessionId!,
96
- server_public_key: config.serverPublicKey!,
97
- session_private_key: config.sessionPrivateKey!,
98
- },
99
- requestConfig: buildRequestConfig(config.proxy),
100
- });
101
- }
102
-
103
- function parseInboundAttachmentRequest(category: string, data: string): MixinAttachmentRequest | null {
104
- if (category !== "PLAIN_DATA" && category !== "PLAIN_AUDIO") {
105
- return null;
106
- }
107
-
108
- try {
109
- const decoded = Buffer.from(data, "base64").toString("utf-8");
110
- const parsed = JSON.parse(decoded) as {
111
- attachment_id?: unknown;
112
- mime_type?: unknown;
113
- size?: unknown;
114
- name?: unknown;
115
- duration?: unknown;
116
- };
117
-
118
- if (typeof parsed.attachment_id !== "string" || !parsed.attachment_id.trim()) {
119
- return null;
120
- }
121
-
122
- return {
123
- attachmentId: parsed.attachment_id.trim(),
124
- mimeType: typeof parsed.mime_type === "string" ? parsed.mime_type.trim() || undefined : undefined,
125
- size: typeof parsed.size === "number" && Number.isFinite(parsed.size) ? parsed.size : undefined,
126
- fileName: typeof parsed.name === "string" ? parsed.name.trim() || undefined : undefined,
127
- duration: typeof parsed.duration === "number" && Number.isFinite(parsed.duration) ? parsed.duration : undefined,
128
- };
129
- } catch {
130
- return null;
131
- }
132
- }
133
-
134
- function formatInboundAttachmentText(category: string, payload: MixinAttachmentRequest): string {
135
- if (category === "PLAIN_AUDIO") {
136
- const details = [
137
- payload.fileName,
138
- payload.mimeType,
139
- typeof payload.duration === "number" ? `${payload.duration}s` : undefined,
140
- typeof payload.size === "number" ? `${payload.size} bytes` : undefined,
141
- ].filter(Boolean);
142
- return details.length > 0 ? `[Mixin audio] ${details.join(" | ")}` : "[Mixin audio]";
143
- }
144
-
145
- const details = [
146
- payload.fileName,
147
- payload.mimeType,
148
- typeof payload.size === "number" ? `${payload.size} bytes` : undefined,
149
- ].filter(Boolean);
150
- return details.length > 0 ? `[Mixin file] ${details.join(" | ")}` : "[Mixin file]";
151
- }
152
-
153
- async function resolveInboundAttachment(params: {
154
- rt: ReturnType<typeof getMixinRuntime>;
155
- config: MixinAccountConfig;
156
- msg: MixinInboundMessage;
157
- log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
158
- }): Promise<{ text: string; mediaPayload?: AgentMediaPayload }> {
159
- const payload = parseInboundAttachmentRequest(params.msg.category, params.msg.data);
160
- if (!payload) {
161
- return {
162
- text: `[${params.msg.category}]`,
163
- };
164
- }
165
-
166
- try {
167
- const client = buildClient(params.config);
168
- const attachment = await client.attachment.fetch(payload.attachmentId);
169
- const fetched = await params.rt.channel.media.fetchRemoteMedia({
170
- url: attachment.view_url,
171
- filePathHint: payload.fileName,
172
- maxBytes: INBOUND_MEDIA_MAX_BYTES,
173
- });
174
- const saved = await params.rt.channel.media.saveMediaBuffer(
175
- fetched.buffer,
176
- payload.mimeType ?? fetched.contentType,
177
- "mixin",
178
- INBOUND_MEDIA_MAX_BYTES,
179
- payload.fileName ?? fetched.fileName,
180
- );
181
-
182
- return {
183
- text: formatInboundAttachmentText(params.msg.category, payload),
184
- mediaPayload: buildAgentMediaPayload([
185
- {
186
- path: saved.path,
187
- contentType: saved.contentType ?? payload.mimeType ?? fetched.contentType,
188
- },
189
- ]),
190
- };
191
- } catch (err) {
192
- params.log.warn(
193
- `[mixin] failed to resolve inbound attachment: messageId=${params.msg.messageId}, category=${params.msg.category}, error=${err instanceof Error ? err.message : String(err)}`,
194
- );
195
- return {
196
- text: formatInboundAttachmentText(params.msg.category, payload),
197
- };
198
- }
199
- }
200
-
201
- function shouldPassGroupFilter(config: MixinAccountConfig, text: string): boolean {
202
- if (!config.requireMentionInGroup) {
203
- return true;
204
- }
205
- const lower = text.toLowerCase();
206
- return lower.includes("?") || /帮我|请|分析|总结|help/i.test(lower);
207
- }
208
-
209
- function isOutboxCommand(text: string): boolean {
210
- return text.trim().toLowerCase().startsWith("/mixin-outbox");
211
- }
212
-
213
- function isOutboxPurgeInvalidCommand(text: string): boolean {
214
- return text.trim().toLowerCase() === "/mixin-outbox purge-invalid";
215
- }
216
-
217
- function formatOutboxStatus(status: Awaited<ReturnType<typeof getOutboxStatus>>): string {
218
- const lines = [
219
- `Outbox pending: ${status.totalPending}`,
220
- `Oldest pending: ${status.oldestPendingAt ?? "N/A"}`,
221
- `Next attempt: ${status.nextAttemptAt ?? "N/A"}`,
222
- `Latest error: ${status.latestError ?? "N/A"}`,
223
- ];
224
-
225
- if (status.pendingByAccount.length > 0) {
226
- lines.push("By account:");
227
- for (const item of status.pendingByAccount) {
228
- lines.push(`- ${item.accountId}: ${item.pending}`);
229
- }
230
- }
231
-
232
- return lines.join("\n");
233
- }
234
-
235
- function normalizeAllowEntry(entry: string): string {
236
- return entry.trim().toLowerCase();
237
- }
238
-
239
- function resolveMixinAllowFromPaths(
240
- rt: ReturnType<typeof getMixinRuntime>,
241
- accountId: string,
242
- ): string[] {
243
- const oauthOverride = process.env.OPENCLAW_OAUTH_DIR?.trim();
244
- const oauthDir = oauthOverride
245
- ? path.resolve(oauthOverride)
246
- : path.join(rt.state.resolveStateDir(process.env, os.homedir), "credentials");
247
- const normalizedAccountId = accountId.trim().toLowerCase();
248
- const paths = [path.join(oauthDir, "mixin-allowFrom.json")];
249
- if (normalizedAccountId) {
250
- paths.unshift(path.join(oauthDir, `mixin-${normalizedAccountId}-allowFrom.json`));
251
- }
252
- return Array.from(new Set(paths));
253
- }
254
-
255
- async function readAllowFromFile(filePath: string): Promise<string[]> {
256
- try {
257
- const raw = await fs.readFile(filePath, "utf8");
258
- const parsed = JSON.parse(raw) as { allowFrom?: unknown };
259
- return Array.isArray(parsed.allowFrom)
260
- ? parsed.allowFrom.map((entry) => String(entry)).map(normalizeAllowEntry).filter(Boolean)
261
- : [];
262
- } catch {
263
- return [];
264
- }
265
- }
266
-
267
- async function readEffectiveAllowFrom(
268
- rt: ReturnType<typeof getMixinRuntime>,
269
- accountId: string,
270
- configAllowFrom: string[],
271
- log?: { info: (m: string) => void },
272
- ): Promise<Set<string>> {
273
- const runtimeAllowFrom = await rt.channel.pairing.readAllowFromStore("mixin", undefined, accountId).catch(() => []);
274
- const filePaths = resolveMixinAllowFromPaths(rt, accountId);
275
- if (!loggedAllowFromAccounts.has(accountId)) {
276
- log?.info(`[mixin] allow-from paths: accountId=${accountId}, paths=${filePaths.join(", ")}`);
277
- loggedAllowFromAccounts.add(accountId);
278
- }
279
- const fileEntries = await Promise.all(filePaths.map((filePath) => readAllowFromFile(filePath)));
280
- const fileAllowFrom = fileEntries.flat();
281
- return new Set([...configAllowFrom, ...runtimeAllowFrom, ...fileAllowFrom].map(normalizeAllowEntry).filter(Boolean));
282
- }
283
-
284
- async function deliverMixinReply(params: {
285
- cfg: OpenClawConfig;
286
- accountId: string;
287
- conversationId: string;
288
- recipientId?: string;
289
- text: string;
290
- log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
291
- }): Promise<void> {
292
- const { cfg, accountId, conversationId, recipientId, text, log } = params;
293
- const plan = buildMixinReplyPlan(text);
294
-
295
- if (!plan) {
296
- return;
297
- }
298
-
299
- if (plan.kind === "text") {
300
- await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
301
- return;
302
- }
303
-
304
- if (plan.kind === "post") {
305
- await sendPostMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
306
- return;
307
- }
308
-
309
- if (plan.kind === "file") {
310
- await sendFileMessage(cfg, accountId, conversationId, recipientId, plan.file, log);
311
- return;
312
- }
313
-
314
- if (plan.kind === "audio") {
315
- await sendAudioMessage(cfg, accountId, conversationId, recipientId, plan.audio, log);
316
- return;
317
- }
318
-
319
- if (plan.kind === "buttons") {
320
- if (plan.intro) {
321
- await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.intro, log);
322
- }
323
- await sendButtonGroupMessage(cfg, accountId, conversationId, recipientId, plan.buttons, log);
324
- return;
325
- }
326
-
327
- await sendCardMessage(cfg, accountId, conversationId, recipientId, plan.card, log);
328
- }
329
-
330
- async function handleUnauthorizedDirectMessage(params: {
331
- rt: ReturnType<typeof getMixinRuntime>;
332
- cfg: OpenClawConfig;
333
- accountId: string;
334
- config: MixinAccountConfig;
335
- msg: MixinInboundMessage;
336
- log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
337
- }): Promise<void> {
338
- const { rt, cfg, accountId, config, msg, log } = params;
339
- const dmPolicy = config.dmPolicy ?? "pairing";
340
-
341
- if (dmPolicy === "disabled") {
342
- return;
343
- }
344
-
345
- const now = Date.now();
346
- const lastNotified = unauthNotifiedUsers.get(msg.userId) ?? 0;
347
- const shouldNotify = lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL;
348
-
349
- if (!shouldNotify) {
350
- return;
351
- }
352
-
353
- pruneUnauthNotifiedUsers(now);
354
- unauthNotifiedUsers.set(msg.userId, now);
355
-
356
- if (dmPolicy === "pairing") {
357
- try {
358
- const { code, created } = await rt.channel.pairing.upsertPairingRequest({
359
- channel: "mixin",
360
- id: msg.userId,
361
- accountId,
362
- meta: {
363
- conversationId: msg.conversationId,
364
- },
365
- });
366
-
367
- if (created && code) {
368
- const reply = rt.channel.pairing.buildPairingReply({
369
- channel: "mixin",
370
- idLine: `Your Mixin UUID: ${msg.userId}`,
371
- code,
372
- });
373
- await sendTextMessage(cfg, accountId, msg.conversationId, msg.userId, reply, log);
374
- }
375
- } catch (err) {
376
- log.error(`[mixin] pairing reply failed for ${msg.userId}`, err);
377
- }
378
- return;
379
- }
380
-
381
- if (dmPolicy === "allowlist") {
382
- const reply = config.allowFrom.length > 0
383
- ? `OpenClaw: access not configured.\n\nYour Mixin UUID: ${msg.userId}\n\nAsk the bot owner to add your Mixin UUID to channels.mixin.allowFrom.`
384
- : `OpenClaw: access not configured.\n\nYour Mixin UUID: ${msg.userId}\n\nAsk the bot owner to add your Mixin UUID to channels.mixin.allowFrom.`;
385
- await sendTextMessage(cfg, accountId, msg.conversationId, msg.userId, reply, log);
386
- }
387
- }
388
-
389
- export async function handleMixinMessage(params: {
390
- cfg: OpenClawConfig;
391
- accountId: string;
392
- msg: MixinInboundMessage;
393
- isDirect: boolean;
394
- log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
395
- }): Promise<void> {
396
- const { cfg, accountId, msg, isDirect, log } = params;
397
- const rt = getMixinRuntime();
398
-
399
- if (isProcessed(msg.messageId)) {
400
- return;
401
- }
402
-
403
- const config = getAccountConfig(cfg, accountId);
404
-
405
- if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
406
- log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
407
- try {
408
- const decrypted = decryptMixinMessage(
409
- msg.data,
410
- config.sessionPrivateKey!,
411
- config.sessionId!,
412
- );
413
- if (!decrypted) {
414
- log.error(`[mixin] decryption failed for ${msg.messageId}`);
415
- markProcessed(msg.messageId);
416
- return;
417
- }
418
- log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
419
- msg.data = Buffer.from(decrypted).toString("base64");
420
- msg.category = "PLAIN_TEXT";
421
- } catch (err) {
422
- log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
423
- markProcessed(msg.messageId);
424
- return;
425
- }
426
- }
427
-
428
- const isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
429
- const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
430
-
431
- if (!isTextMessage && !isAttachmentMessage) {
432
- log.info(`[mixin] skip non-text message: ${msg.category}`);
433
- return;
434
- }
435
-
436
- let text = decodeContent(msg.category, msg.data).trim();
437
- let mediaPayload: AgentMediaPayload | undefined;
438
- if (isAttachmentMessage) {
439
- const resolved = await resolveInboundAttachment({ rt, config, msg, log });
440
- text = resolved.text.trim();
441
- mediaPayload = resolved.mediaPayload;
442
- }
443
- log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
444
-
445
- if (!text) {
446
- return;
447
- }
448
-
449
- if (!isDirect && !isAttachmentMessage && !shouldPassGroupFilter(config, text)) {
450
- log.info(`[mixin] group message filtered: ${msg.messageId}`);
451
- return;
452
- }
453
-
454
- const effectiveAllowFrom = await readEffectiveAllowFrom(rt, accountId, config.allowFrom, log);
455
- const normalizedUserId = normalizeAllowEntry(msg.userId);
456
- const dmPolicy = config.dmPolicy ?? "pairing";
457
- const isAuthorized = dmPolicy === "open" || effectiveAllowFrom.has(normalizedUserId);
458
-
459
- if (!isAuthorized) {
460
- log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
461
- markProcessed(msg.messageId);
462
- if (isDirect) {
463
- await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
464
- }
465
- return;
466
- }
467
-
468
- markProcessed(msg.messageId);
469
-
470
- if (isOutboxCommand(text)) {
471
- if (isOutboxPurgeInvalidCommand(text)) {
472
- const result = await purgePermanentInvalidOutboxEntries();
473
- const recipientId = isDirect ? msg.userId : undefined;
474
- const replyText = result.removed > 0
475
- ? `Removed ${result.removed} invalid outbox entr${result.removed === 1 ? "y" : "ies"}.\n${result.removedJobIds.map((jobId) => `- ${jobId}`).join("\n")}`
476
- : "No invalid outbox entries found.";
477
- await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, replyText, log);
478
- return;
479
- }
480
-
481
- const status = await getOutboxStatus();
482
- const replyText = formatOutboxStatus(status);
483
- const recipientId = isDirect ? msg.userId : undefined;
484
- await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, replyText, log);
485
- return;
486
- }
487
-
488
- const peerId = isDirect ? msg.userId : msg.conversationId;
489
- log.info(`[mixin] resolving route: channel=mixin, accountId=${accountId}, peer.kind=${isDirect ? "direct" : "group"}, peer.id=${peerId}`);
490
-
491
- const route = rt.channel.routing.resolveAgentRoute({
492
- cfg,
493
- channel: "mixin",
494
- accountId,
495
- peer: {
496
- kind: isDirect ? "direct" : "group",
497
- id: peerId,
498
- },
499
- });
500
-
501
- log.info(`[mixin] route result: ${route ? "FOUND" : "NULL"} - agentId=${route?.agentId ?? "N/A"}`);
502
-
503
- if (!route) {
504
- log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
505
- return;
506
- }
507
-
508
- const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(text, cfg);
509
- const useAccessGroups = cfg.commands?.useAccessGroups !== false;
510
- const senderAllowedForCommands = useAccessGroups ? effectiveAllowFrom.has(normalizedUserId) : true;
511
-
512
- const commandAuthorized = shouldComputeCommandAuthorized
513
- ? rt.channel.commands.resolveCommandAuthorizedFromAuthorizers({
514
- useAccessGroups,
515
- authorizers: [
516
- {
517
- configured: effectiveAllowFrom.size > 0,
518
- allowed: senderAllowedForCommands,
519
- },
520
- ],
521
- })
522
- : undefined;
523
-
524
- const ctx = rt.channel.reply.finalizeInboundContext({
525
- Body: text,
526
- RawBody: text,
527
- CommandBody: text,
528
- From: isDirect ? msg.userId : msg.conversationId,
529
- SessionKey: route.sessionKey,
530
- AccountId: accountId,
531
- ChatType: isDirect ? "direct" : "group",
532
- Provider: "mixin",
533
- Surface: "mixin",
534
- MessageSid: msg.messageId,
535
- CommandAuthorized: commandAuthorized,
536
- OriginatingChannel: "mixin",
537
- OriginatingTo: isDirect ? msg.userId : msg.conversationId,
538
- ...mediaPayload,
539
- });
540
-
541
- const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
542
- agentId: route.agentId,
543
- });
544
- await rt.channel.session.recordInboundSession({
545
- storePath,
546
- sessionKey: route.sessionKey,
547
- ctx,
548
- onRecordError: (err: unknown) => {
549
- log.error("[mixin] session record error", err);
550
- },
551
- });
552
-
553
- log.info(`[mixin] dispatching ${msg.messageId} from ${msg.userId}`);
554
-
555
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
556
- ctx,
557
- cfg,
558
- dispatcherOptions: {
559
- deliver: async (payload) => {
560
- const replyText = payload.text ?? "";
561
- if (!replyText) {
562
- return;
563
- }
564
- const recipientId = isDirect ? msg.userId : undefined;
565
- await deliverMixinReply({
566
- cfg,
567
- accountId,
568
- conversationId: msg.conversationId,
569
- recipientId,
570
- text: replyText,
571
- log,
572
- });
573
- },
574
- },
575
- });
576
- }
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";
8
+ import { getAccountConfig, resolveConversationPolicy } from "./config.js";
9
+ import type { MixinAccountConfig } from "./config-schema.js";
10
+ import { decryptMixinMessage } from "./crypto.js";
11
+ import { getMixpayOrderStatusText, getRecentMixpayOrdersText, refreshMixpayOrderStatus } from "./mixpay-worker.js";
12
+ import { buildMixinOutboundPlanFromReplyText, executeMixinOutboundPlan } from "./outbound-plan.js";
13
+ import { getMixinRuntime } from "./runtime.js";
14
+ import {
15
+ getOutboxStatus,
16
+ purgePermanentInvalidOutboxEntries,
17
+ sendTextMessage,
18
+ } from "./send-service.js";
19
+ import { buildClient } from "./shared.js";
20
+
21
+ export interface MixinInboundMessage {
22
+ conversationId: string;
23
+ userId: string;
24
+ messageId: string;
25
+ category: string;
26
+ data: string;
27
+ createdAt: string;
28
+ publicKey?: string;
29
+ }
30
+
31
+ const processedMessages = new Set<string>();
32
+ const MAX_DEDUP_SIZE = 2000;
33
+ const unauthNotifiedUsers = new Map<string, number>();
34
+ const unauthNotifiedGroups = new Map<string, number>();
35
+ const loggedAllowFromAccounts = new Set<string>();
36
+ const UNAUTH_NOTIFY_INTERVAL = 20 * 60 * 1000;
37
+ const MAX_UNAUTH_NOTIFY_USERS = 1000;
38
+ const MAX_UNAUTH_NOTIFY_GROUPS = 1000;
39
+ const INBOUND_MEDIA_MAX_BYTES = 30 * 1024 * 1024;
40
+ const USER_PROFILE_CACHE_TTL_MS = 10 * 60 * 1000;
41
+ const MAX_USER_PROFILE_CACHE = 2000;
42
+ const GROUP_PROFILE_CACHE_TTL_MS = 10 * 60 * 1000;
43
+ const MAX_GROUP_PROFILE_CACHE = 1000;
44
+ const BOT_PROFILE_CACHE_TTL_MS = 10 * 60 * 1000;
45
+ const SESSION_LABEL_MAX_LENGTH = 64;
46
+ const requireFromHere = createRequire(import.meta.url);
47
+
48
+ type CachedUserProfile = {
49
+ fullName: string;
50
+ expiresAt: number;
51
+ };
52
+
53
+ type CachedGroupProfile = {
54
+ name: string;
55
+ expiresAt: number;
56
+ };
57
+
58
+ type CachedBotProfile = {
59
+ name: string;
60
+ expiresAt: number;
61
+ };
62
+
63
+ type MixinAttachmentRequest = {
64
+ attachmentId: string;
65
+ mimeType?: string;
66
+ size?: number;
67
+ fileName?: string;
68
+ duration?: number;
69
+ };
70
+
71
+ const cachedUserProfiles = new Map<string, CachedUserProfile>();
72
+ const cachedGroupProfiles = new Map<string, CachedGroupProfile>();
73
+ const cachedBotProfiles = new Map<string, CachedBotProfile>();
74
+ let cachedUpdateSessionStore:
75
+ | ((storePath: string, mutator: (store: Record<string, Record<string, unknown>>) => void | Promise<void>) => Promise<unknown>)
76
+ | null
77
+ | undefined;
78
+
79
+ function isProcessed(messageId: string): boolean {
80
+ return processedMessages.has(messageId);
81
+ }
82
+
83
+ function markProcessed(messageId: string): void {
84
+ if (processedMessages.size >= MAX_DEDUP_SIZE) {
85
+ const first = processedMessages.values().next().value;
86
+ if (first) {
87
+ processedMessages.delete(first);
88
+ }
89
+ }
90
+ processedMessages.add(messageId);
91
+ }
92
+
93
+ function pruneUnauthNotifiedUsers(now: number): void {
94
+ for (const [userId, lastNotified] of unauthNotifiedUsers) {
95
+ if (now - lastNotified > UNAUTH_NOTIFY_INTERVAL) {
96
+ unauthNotifiedUsers.delete(userId);
97
+ }
98
+ }
99
+
100
+ while (unauthNotifiedUsers.size >= MAX_UNAUTH_NOTIFY_USERS) {
101
+ const first = unauthNotifiedUsers.keys().next().value;
102
+ if (!first) {
103
+ break;
104
+ }
105
+ unauthNotifiedUsers.delete(first);
106
+ }
107
+ }
108
+
109
+ function pruneUnauthNotifiedGroups(now: number): void {
110
+ for (const [conversationId, lastNotified] of unauthNotifiedGroups) {
111
+ if (now - lastNotified > UNAUTH_NOTIFY_INTERVAL) {
112
+ unauthNotifiedGroups.delete(conversationId);
113
+ }
114
+ }
115
+
116
+ while (unauthNotifiedGroups.size >= MAX_UNAUTH_NOTIFY_GROUPS) {
117
+ const first = unauthNotifiedGroups.keys().next().value;
118
+ if (!first) {
119
+ break;
120
+ }
121
+ unauthNotifiedGroups.delete(first);
122
+ }
123
+ }
124
+
125
+ function decodeContent(category: string, data: string): string {
126
+ if (category.startsWith("PLAIN_TEXT") || category.startsWith("PLAIN_POST")) {
127
+ try {
128
+ return Buffer.from(data, "base64").toString("utf-8");
129
+ } catch {
130
+ return data;
131
+ }
132
+ }
133
+ return `[${category}]`;
134
+ }
135
+
136
+ function buildUserProfileCacheKey(accountId: string, userId: string): string {
137
+ return `${accountId}:${userId.trim().toLowerCase()}`;
138
+ }
139
+
140
+ function buildGroupProfileCacheKey(accountId: string, conversationId: string): string {
141
+ return `${accountId}:${conversationId.trim().toLowerCase()}`;
142
+ }
143
+
144
+ function buildBotProfileCacheKey(accountId: string): string {
145
+ return accountId.trim().toLowerCase();
146
+ }
147
+
148
+ function pruneUserProfileCache(now: number): void {
149
+ for (const [key, cached] of cachedUserProfiles) {
150
+ if (cached.expiresAt <= now) {
151
+ cachedUserProfiles.delete(key);
152
+ }
153
+ }
154
+
155
+ while (cachedUserProfiles.size >= MAX_USER_PROFILE_CACHE) {
156
+ const first = cachedUserProfiles.keys().next().value;
157
+ if (!first) {
158
+ break;
159
+ }
160
+ cachedUserProfiles.delete(first);
161
+ }
162
+ }
163
+
164
+ function pruneGroupProfileCache(now: number): void {
165
+ for (const [key, cached] of cachedGroupProfiles) {
166
+ if (cached.expiresAt <= now) {
167
+ cachedGroupProfiles.delete(key);
168
+ }
169
+ }
170
+
171
+ while (cachedGroupProfiles.size >= MAX_GROUP_PROFILE_CACHE) {
172
+ const first = cachedGroupProfiles.keys().next().value;
173
+ if (!first) {
174
+ break;
175
+ }
176
+ cachedGroupProfiles.delete(first);
177
+ }
178
+ }
179
+
180
+ function pruneBotProfileCache(now: number): void {
181
+ for (const [key, cached] of cachedBotProfiles) {
182
+ if (cached.expiresAt <= now) {
183
+ cachedBotProfiles.delete(key);
184
+ }
185
+ }
186
+ }
187
+
188
+ function normalizePresentationName(value: string): string {
189
+ return value.replace(/\s+/g, " ").trim();
190
+ }
191
+
192
+ function sliceUtf16Safe(value: string, maxLength: number): string {
193
+ if (value.length <= maxLength) {
194
+ return value;
195
+ }
196
+ let sliced = value.slice(0, maxLength);
197
+ const lastCodeUnit = sliced.charCodeAt(sliced.length - 1);
198
+ if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) {
199
+ sliced = sliced.slice(0, -1);
200
+ }
201
+ return sliced;
202
+ }
203
+
204
+ function clampSessionLabel(label: string): string {
205
+ const trimmed = normalizePresentationName(label);
206
+ if (!trimmed) {
207
+ return "";
208
+ }
209
+ if (trimmed.length <= SESSION_LABEL_MAX_LENGTH) {
210
+ return trimmed;
211
+ }
212
+ return sliceUtf16Safe(trimmed, SESSION_LABEL_MAX_LENGTH);
213
+ }
214
+
215
+ async function loadUpdateSessionStore(log: {
216
+ info: (m: string) => void;
217
+ warn: (m: string) => void;
218
+ error: (m: string, e?: unknown) => void;
219
+ }): Promise<
220
+ ((storePath: string, mutator: (store: Record<string, Record<string, unknown>>) => void | Promise<void>) => Promise<unknown>) | null
221
+ > {
222
+ if (cachedUpdateSessionStore !== undefined) {
223
+ return cachedUpdateSessionStore;
224
+ }
225
+
226
+ try {
227
+ const openclawEntryPath = requireFromHere.resolve("openclaw");
228
+ const openclawEntryDir = path.dirname(openclawEntryPath);
229
+ const distDir = path.basename(openclawEntryDir).toLowerCase() === "dist" ? openclawEntryDir : path.join(openclawEntryDir, "dist");
230
+
231
+ const entries = await fs.readdir(distDir, { withFileTypes: true });
232
+ const sessionModules = entries
233
+ .filter((entry) => entry.isFile() && /^sessions-.*\.js$/i.test(entry.name))
234
+ .map((entry) => entry.name)
235
+ .sort();
236
+ if (!sessionModules.length) {
237
+ cachedUpdateSessionStore = null;
238
+ return null;
239
+ }
240
+
241
+ for (const sessionModule of sessionModules) {
242
+ try {
243
+ const moduleUrl = pathToFileURL(path.join(distDir, sessionModule)).href;
244
+ const imported = (await import(moduleUrl)) as Record<string, unknown>;
245
+ const candidate = Object.values(imported).find((val) => {
246
+ if (typeof val !== "function" || val.length !== 2) {
247
+ return false;
248
+ }
249
+ return true;
250
+ });
251
+ if (candidate) {
252
+ cachedUpdateSessionStore = candidate as (
253
+ storePath: string,
254
+ mutator: (store: Record<string, Record<string, unknown>>) => void | Promise<void>,
255
+ ) => Promise<unknown>;
256
+ return cachedUpdateSessionStore;
257
+ }
258
+ } catch {
259
+ continue;
260
+ }
261
+ }
262
+
263
+ log.warn("[mixin] no matching updateSessionStore export found in session modules");
264
+ cachedUpdateSessionStore = null;
265
+ return null;
266
+ } catch (err) {
267
+ log.warn(
268
+ `[mixin] failed to load OpenClaw session store updater: error=${err instanceof Error ? err.message : String(err)}`,
269
+ );
270
+ cachedUpdateSessionStore = null;
271
+ return null;
272
+ }
273
+ }
274
+
275
+ async function resolveSenderName(params: {
276
+ accountId: string;
277
+ config: MixinAccountConfig;
278
+ userId: string;
279
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
280
+ }): Promise<string> {
281
+ const userId = params.userId.trim();
282
+ if (!userId) {
283
+ return "";
284
+ }
285
+
286
+ const now = Date.now();
287
+ const cacheKey = buildUserProfileCacheKey(params.accountId, userId);
288
+ const cached = cachedUserProfiles.get(cacheKey);
289
+ if (cached && cached.expiresAt > now) {
290
+ return cached.fullName;
291
+ }
292
+
293
+ pruneUserProfileCache(now);
294
+
295
+ try {
296
+ const client = buildClient(params.config);
297
+ const user = await client.user.fetch(userId);
298
+ const fullName = typeof user.full_name === "string" && user.full_name.trim() ? user.full_name.trim() : userId;
299
+ cachedUserProfiles.set(cacheKey, {
300
+ fullName,
301
+ expiresAt: now + USER_PROFILE_CACHE_TTL_MS,
302
+ });
303
+ return fullName;
304
+ } catch (err) {
305
+ params.log.warn(
306
+ `[mixin] failed to resolve sender profile: accountId=${params.accountId}, userId=${userId}, error=${err instanceof Error ? err.message : String(err)}`,
307
+ );
308
+ return userId;
309
+ }
310
+ }
311
+
312
+ async function resolveGroupName(params: {
313
+ accountId: string;
314
+ config: MixinAccountConfig;
315
+ conversationId: string;
316
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
317
+ }): Promise<string> {
318
+ const conversationId = params.conversationId.trim();
319
+ if (!conversationId) {
320
+ return "";
321
+ }
322
+
323
+ const now = Date.now();
324
+ const cacheKey = buildGroupProfileCacheKey(params.accountId, conversationId);
325
+ const cached = cachedGroupProfiles.get(cacheKey);
326
+ if (cached && cached.expiresAt > now) {
327
+ return cached.name;
328
+ }
329
+
330
+ pruneGroupProfileCache(now);
331
+
332
+ try {
333
+ const client = buildClient(params.config);
334
+ const conversation = await client.conversation.fetch(conversationId);
335
+ const name = normalizePresentationName(String(conversation.name ?? "")) || conversationId;
336
+ cachedGroupProfiles.set(cacheKey, {
337
+ name,
338
+ expiresAt: now + GROUP_PROFILE_CACHE_TTL_MS,
339
+ });
340
+ return name;
341
+ } catch (err) {
342
+ params.log.warn(
343
+ `[mixin] failed to resolve group profile: accountId=${params.accountId}, conversationId=${conversationId}, error=${err instanceof Error ? err.message : String(err)}`,
344
+ );
345
+ return conversationId;
346
+ }
347
+ }
348
+
349
+ async function resolveBotName(params: {
350
+ accountId: string;
351
+ config: MixinAccountConfig;
352
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
353
+ }): Promise<string> {
354
+ const configuredName = normalizePresentationName(params.config.name ?? "");
355
+ if (configuredName) {
356
+ return configuredName;
357
+ }
358
+
359
+ const now = Date.now();
360
+ const cacheKey = buildBotProfileCacheKey(params.accountId);
361
+ const cached = cachedBotProfiles.get(cacheKey);
362
+ if (cached && cached.expiresAt > now) {
363
+ return cached.name;
364
+ }
365
+
366
+ pruneBotProfileCache(now);
367
+
368
+ try {
369
+ const client = buildClient(params.config);
370
+ const profile = await client.user.profile();
371
+ const name = normalizePresentationName(String(profile.full_name ?? "")) || params.accountId;
372
+ cachedBotProfiles.set(cacheKey, {
373
+ name,
374
+ expiresAt: now + BOT_PROFILE_CACHE_TTL_MS,
375
+ });
376
+ return name;
377
+ } catch (err) {
378
+ params.log.warn(
379
+ `[mixin] failed to resolve bot profile: accountId=${params.accountId}, error=${err instanceof Error ? err.message : String(err)}`,
380
+ );
381
+ return params.accountId;
382
+ }
383
+ }
384
+
385
+ async function updateSessionPresentation(params: {
386
+ storePath: string;
387
+ sessionKey: string;
388
+ label: string;
389
+ displayName?: string;
390
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
391
+ }): Promise<void> {
392
+ const nextLabel = clampSessionLabel(params.label);
393
+ const nextDisplayName = clampSessionLabel(params.displayName ?? "");
394
+ if (!nextLabel && !nextDisplayName) {
395
+ return;
396
+ }
397
+
398
+ try {
399
+ const updateSessionStore = await loadUpdateSessionStore(params.log);
400
+ if (!updateSessionStore) {
401
+ return;
402
+ }
403
+ await updateSessionStore(params.storePath, (store: Record<string, Record<string, unknown>>) => {
404
+ const entry = store[params.sessionKey];
405
+ if (!entry || typeof entry !== "object") {
406
+ return;
407
+ }
408
+
409
+ let changed = false;
410
+ if (nextLabel && entry.label !== nextLabel) {
411
+ entry.label = nextLabel;
412
+ changed = true;
413
+ }
414
+ if (nextDisplayName && entry.displayName !== nextDisplayName) {
415
+ entry.displayName = nextDisplayName;
416
+ changed = true;
417
+ }
418
+ if (changed) {
419
+ entry.updatedAt = new Date().toISOString();
420
+ }
421
+ });
422
+ } catch (err) {
423
+ params.log.warn(
424
+ `[mixin] failed to update session presentation: sessionKey=${params.sessionKey}, error=${err instanceof Error ? err.message : String(err)}`,
425
+ );
426
+ }
427
+ }
428
+
429
+ function resolveInboundMediaMaxBytes(config: MixinAccountConfig): number {
430
+ const mediaMaxMb = config.mediaMaxMb;
431
+ if (typeof mediaMaxMb === "number" && Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
432
+ return Math.max(1, Math.floor(mediaMaxMb * 1024 * 1024));
433
+ }
434
+ return INBOUND_MEDIA_MAX_BYTES;
435
+ }
436
+
437
+ function parseInboundAttachmentRequest(category: string, data: string): MixinAttachmentRequest | null {
438
+ if (category !== "PLAIN_DATA" && category !== "PLAIN_AUDIO") {
439
+ return null;
440
+ }
441
+
442
+ try {
443
+ const decoded = Buffer.from(data, "base64").toString("utf-8");
444
+ const parsed = JSON.parse(decoded) as {
445
+ attachment_id?: unknown;
446
+ mime_type?: unknown;
447
+ size?: unknown;
448
+ name?: unknown;
449
+ duration?: unknown;
450
+ };
451
+
452
+ if (typeof parsed.attachment_id !== "string" || !parsed.attachment_id.trim()) {
453
+ return null;
454
+ }
455
+
456
+ return {
457
+ attachmentId: parsed.attachment_id.trim(),
458
+ mimeType: typeof parsed.mime_type === "string" ? parsed.mime_type.trim() || undefined : undefined,
459
+ size: typeof parsed.size === "number" && Number.isFinite(parsed.size) ? parsed.size : undefined,
460
+ fileName: typeof parsed.name === "string" ? parsed.name.trim() || undefined : undefined,
461
+ duration: typeof parsed.duration === "number" && Number.isFinite(parsed.duration) ? parsed.duration : undefined,
462
+ };
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+
468
+ function formatInboundAttachmentText(category: string, payload: MixinAttachmentRequest): string {
469
+ if (category === "PLAIN_AUDIO") {
470
+ const details = [
471
+ payload.fileName,
472
+ payload.mimeType,
473
+ typeof payload.duration === "number" ? `${payload.duration}s` : undefined,
474
+ typeof payload.size === "number" ? `${payload.size} bytes` : undefined,
475
+ ].filter(Boolean);
476
+ return details.length > 0 ? `[Mixin audio] ${details.join(" | ")}` : "[Mixin audio]";
477
+ }
478
+
479
+ const details = [
480
+ payload.fileName,
481
+ payload.mimeType,
482
+ typeof payload.size === "number" ? `${payload.size} bytes` : undefined,
483
+ ].filter(Boolean);
484
+ return details.length > 0 ? `[Mixin file] ${details.join(" | ")}` : "[Mixin file]";
485
+ }
486
+
487
+ async function resolveInboundAttachment(params: {
488
+ rt: ReturnType<typeof getMixinRuntime>;
489
+ config: MixinAccountConfig;
490
+ msg: MixinInboundMessage;
491
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
492
+ }): Promise<{ text: string; mediaPayload?: AgentMediaPayload }> {
493
+ const payload = parseInboundAttachmentRequest(params.msg.category, params.msg.data);
494
+ if (!payload) {
495
+ return {
496
+ text: `[${params.msg.category}]`,
497
+ };
498
+ }
499
+
500
+ try {
501
+ const client = buildClient(params.config);
502
+ const maxBytes = resolveInboundMediaMaxBytes(params.config);
503
+ const attachment = await client.attachment.fetch(payload.attachmentId);
504
+ const fetched = await params.rt.channel.media.fetchRemoteMedia({
505
+ url: attachment.view_url,
506
+ filePathHint: payload.fileName,
507
+ maxBytes,
508
+ });
509
+ const saved = await params.rt.channel.media.saveMediaBuffer(
510
+ fetched.buffer,
511
+ payload.mimeType ?? fetched.contentType,
512
+ "mixin",
513
+ maxBytes,
514
+ payload.fileName ?? fetched.fileName,
515
+ );
516
+
517
+ return {
518
+ text: formatInboundAttachmentText(params.msg.category, payload),
519
+ mediaPayload: buildAgentMediaPayload([
520
+ {
521
+ path: saved.path,
522
+ contentType: saved.contentType ?? payload.mimeType ?? fetched.contentType,
523
+ },
524
+ ]),
525
+ };
526
+ } catch (err) {
527
+ params.log.warn(
528
+ `[mixin] failed to resolve inbound attachment: messageId=${params.msg.messageId}, category=${params.msg.category}, error=${err instanceof Error ? err.message : String(err)}`,
529
+ );
530
+ return {
531
+ text: formatInboundAttachmentText(params.msg.category, payload),
532
+ };
533
+ }
534
+ }
535
+
536
+ function shouldPassGroupFilter(config: MixinAccountConfig, text: string): boolean {
537
+ if (!config.requireMentionInGroup) {
538
+ return true;
539
+ }
540
+ if (text.trim().startsWith("/")) {
541
+ return true;
542
+ }
543
+ const lower = text.toLowerCase();
544
+ return lower.includes("?") || /帮我|请|分析|总结|help/i.test(lower);
545
+ }
546
+
547
+ function isOutboxCommand(text: string): boolean {
548
+ return /(^|\s)\/mixin-outbox(?:\s|$)/i.test(text.trim());
549
+ }
550
+
551
+ function isOutboxPurgeInvalidCommand(text: string): boolean {
552
+ return /(^|\s)\/mixin-outbox\s+purge-invalid(?:\s|$)/i.test(normalizeCommandText(text));
553
+ }
554
+
555
+ function isMixinGroupAuthCommand(text: string): boolean {
556
+ return /(^|\s)\/mixin-group-auth(?:\s|$)/i.test(normalizeCommandText(text));
557
+ }
558
+
559
+ function isMixinGroupApproveCommand(text: string): boolean {
560
+ return /(^|\s)\/mixin-group-approve\s+\S+(?:\s|$)/i.test(normalizeCommandText(text));
561
+ }
562
+
563
+ function parseMixinGroupApproveCode(text: string): string | null {
564
+ const match = normalizeCommandText(text).match(/(?:^|\s)\/mixin-group-approve\s+(\S+)(?:\s|$)/i);
565
+ return match?.[1]?.trim() || null;
566
+ }
567
+
568
+ function isCollectStatusCommand(text: string): boolean {
569
+ return /(^|\s)\/collect\s+status\s+\S+(?:\s|$)/i.test(normalizeCommandText(text));
570
+ }
571
+
572
+ function isCollectRecentCommand(text: string): boolean {
573
+ return /(^|\s)\/collect\s+recent(?:\s+\d+)?(?:\s|$)/i.test(normalizeCommandText(text));
574
+ }
575
+
576
+ function parseCollectStatusCommand(text: string): string | null {
577
+ const match = normalizeCommandText(text).match(/(?:^|\s)\/collect\s+status\s+(\S+)(?:\s|$)/i);
578
+ return match?.[1]?.trim() || null;
579
+ }
580
+
581
+ function parseCollectRecentLimit(text: string): number {
582
+ const match = normalizeCommandText(text).match(/(?:^|\s)\/collect\s+recent(?:\s+(\d+))?(?:\s|$)/i);
583
+ const parsed = Number.parseInt(match?.[1] ?? "", 10);
584
+ if (!Number.isFinite(parsed) || parsed <= 0) {
585
+ return 5;
586
+ }
587
+ return Math.min(parsed, 20);
588
+ }
589
+
590
+ function normalizeCommandText(text: string): string {
591
+ return text
592
+ .replace(/[\u200B-\u200D\uFEFF]/g, "")
593
+ .replace(/\s+/g, " ")
594
+ .trim();
595
+ }
596
+
597
+ function formatOutboxStatus(status: Awaited<ReturnType<typeof getOutboxStatus>>): string {
598
+ const lines = [
599
+ `Outbox pending: ${status.totalPending}`,
600
+ `Oldest pending: ${status.oldestPendingAt ?? "N/A"}`,
601
+ `Next attempt: ${status.nextAttemptAt ?? "N/A"}`,
602
+ `Latest error: ${status.latestError ?? "N/A"}`,
603
+ ];
604
+
605
+ if (status.pendingByAccount.length > 0) {
606
+ lines.push("By account:");
607
+ for (const item of status.pendingByAccount) {
608
+ lines.push(`- ${item.accountId}: ${item.pending}`);
609
+ }
610
+ }
611
+
612
+ return lines.join("\n");
613
+ }
614
+
615
+ function formatMixinGroupAuthReply(params: {
616
+ code: string;
617
+ created: boolean;
618
+ conversationId: string;
619
+ accountId: string;
620
+ }): string {
621
+ const lines = [
622
+ params.created ? "Group auth request created." : "Group auth request already exists.",
623
+ `Code: ${params.code}`,
624
+ `conversationId: ${params.conversationId}`,
625
+ "",
626
+ "Approve it in the OpenClaw terminal with:",
627
+ params.accountId === "default"
628
+ ? `openclaw pairing approve mixin ${params.code}`
629
+ : `openclaw pairing approve --account ${params.accountId} mixin ${params.code}`,
630
+ ];
631
+ return lines.join("\n");
632
+ }
633
+
634
+ function buildGroupScopedPairingId(params: {
635
+ conversationId: string;
636
+ }): string {
637
+ return `group:${params.conversationId.trim().toLowerCase()}`;
638
+ }
639
+
640
+ function buildGroupScopedPairingMeta(params: {
641
+ conversationId: string;
642
+ userId: string;
643
+ }): Record<string, string> {
644
+ return {
645
+ kind: "group-auth",
646
+ conversationId: params.conversationId.trim(),
647
+ requestedBy: params.userId.trim(),
648
+ };
649
+ }
650
+
651
+ function normalizeAllowEntry(entry: string): string {
652
+ return entry.trim().toLowerCase();
653
+ }
654
+
655
+ function normalizeAllowEntries(entries: string[] | undefined): string[] {
656
+ return (entries ?? []).map(normalizeAllowEntry).filter(Boolean);
657
+ }
658
+
659
+ function resolveMixinAllowFromPaths(
660
+ rt: ReturnType<typeof getMixinRuntime>,
661
+ accountId: string,
662
+ ): string[] {
663
+ const oauthOverride = process.env.OPENCLAW_OAUTH_DIR?.trim();
664
+ const oauthDir = oauthOverride
665
+ ? path.resolve(oauthOverride)
666
+ : path.join(rt.state.resolveStateDir(process.env, os.homedir), "credentials");
667
+ const normalizedAccountId = accountId.trim().toLowerCase();
668
+ const paths = [path.join(oauthDir, "mixin-allowFrom.json")];
669
+ if (normalizedAccountId) {
670
+ paths.unshift(path.join(oauthDir, `mixin-${normalizedAccountId}-allowFrom.json`));
671
+ }
672
+ return Array.from(new Set(paths));
673
+ }
674
+
675
+ async function readAllowFromFile(filePath: string): Promise<string[]> {
676
+ try {
677
+ const raw = await fs.readFile(filePath, "utf8");
678
+ const parsed = JSON.parse(raw) as { allowFrom?: unknown };
679
+ return Array.isArray(parsed.allowFrom)
680
+ ? parsed.allowFrom.map((entry) => String(entry)).map(normalizeAllowEntry).filter(Boolean)
681
+ : [];
682
+ } catch {
683
+ return [];
684
+ }
685
+ }
686
+
687
+ async function readEffectiveAllowFrom(
688
+ rt: ReturnType<typeof getMixinRuntime>,
689
+ accountId: string,
690
+ configAllowFrom: string[],
691
+ log?: { info: (m: string) => void },
692
+ ): Promise<Set<string>> {
693
+ const runtimeAllowFrom = await rt.channel.pairing.readAllowFromStore("mixin", undefined, accountId).catch(() => []);
694
+ const filePaths = resolveMixinAllowFromPaths(rt, accountId);
695
+ if (!loggedAllowFromAccounts.has(accountId)) {
696
+ log?.info(`[mixin] allow-from paths: accountId=${accountId}, paths=${filePaths.join(", ")}`);
697
+ loggedAllowFromAccounts.add(accountId);
698
+ }
699
+ const fileEntries = await Promise.all(filePaths.map((filePath) => readAllowFromFile(filePath)));
700
+ const fileAllowFrom = fileEntries.flat();
701
+ return new Set([...configAllowFrom, ...runtimeAllowFrom, ...fileAllowFrom].map(normalizeAllowEntry).filter(Boolean));
702
+ }
703
+
704
+ async function deliverMixinReply(params: {
705
+ cfg: OpenClawConfig;
706
+ accountId: string;
707
+ conversationId: string;
708
+ recipientId?: string;
709
+ creatorId?: string;
710
+ text: string;
711
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
712
+ }): Promise<void> {
713
+ const { cfg, accountId, conversationId, recipientId, creatorId, text, log } = params;
714
+ const plan = buildMixinOutboundPlanFromReplyText(text);
715
+ if (plan.steps.length === 0) {
716
+ return;
717
+ }
718
+ for (const warning of plan.warnings) {
719
+ log.warn(`[mixin] outbound plan warning: ${warning}`);
720
+ }
721
+ await executeMixinOutboundPlan({
722
+ cfg,
723
+ accountId,
724
+ conversationId,
725
+ recipientId,
726
+ creatorId,
727
+ steps: plan.steps,
728
+ log,
729
+ });
730
+ }
731
+
732
+ async function handleUnauthorizedDirectMessage(params: {
733
+ rt: ReturnType<typeof getMixinRuntime>;
734
+ cfg: OpenClawConfig;
735
+ accountId: string;
736
+ config: MixinAccountConfig;
737
+ msg: MixinInboundMessage;
738
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
739
+ }): Promise<void> {
740
+ const { rt, cfg, accountId, config, msg, log } = params;
741
+ const dmPolicy = config.dmPolicy ?? "pairing";
742
+
743
+ if (dmPolicy === "disabled") {
744
+ return;
745
+ }
746
+
747
+ const now = Date.now();
748
+ const lastNotified = unauthNotifiedUsers.get(msg.userId) ?? 0;
749
+ const shouldNotify = lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL;
750
+
751
+ if (!shouldNotify) {
752
+ return;
753
+ }
754
+
755
+ pruneUnauthNotifiedUsers(now);
756
+ unauthNotifiedUsers.set(msg.userId, now);
757
+
758
+ if (dmPolicy === "pairing") {
759
+ try {
760
+ const { code, created } = await rt.channel.pairing.upsertPairingRequest({
761
+ channel: "mixin",
762
+ id: msg.userId,
763
+ accountId,
764
+ meta: {
765
+ conversationId: msg.conversationId,
766
+ },
767
+ });
768
+
769
+ if (created && code) {
770
+ const reply = rt.channel.pairing.buildPairingReply({
771
+ channel: "mixin",
772
+ idLine: `Your Mixin UUID: ${msg.userId}`,
773
+ code,
774
+ });
775
+ await sendTextMessage(cfg, accountId, msg.conversationId, msg.userId, reply, log);
776
+ }
777
+ } catch (err) {
778
+ log.error(`[mixin] pairing reply failed for ${msg.userId}`, err);
779
+ }
780
+ return;
781
+ }
782
+
783
+ if (dmPolicy === "allowlist") {
784
+ const reply = `OpenClaw: access not configured.\n\nYour Mixin UUID: ${msg.userId}\n\nAsk the bot owner to add your Mixin UUID to channels.mixin.allowFrom.`;
785
+ await sendTextMessage(cfg, accountId, msg.conversationId, msg.userId, reply, log);
786
+ }
787
+ }
788
+
789
+ function evaluateMixinGroupAccess(params: {
790
+ cfg: OpenClawConfig;
791
+ config: MixinAccountConfig;
792
+ accountId: string;
793
+ conversationId: string;
794
+ senderId: string;
795
+ }): {
796
+ allowed: boolean;
797
+ reason: string;
798
+ groupPolicy: "open" | "disabled" | "allowlist";
799
+ groupAllowFrom: string[];
800
+ } {
801
+ const conversationPolicy = resolveConversationPolicy(params.cfg, params.accountId, params.conversationId);
802
+ if (!conversationPolicy.enabled) {
803
+ return {
804
+ allowed: false,
805
+ reason: "conversation disabled",
806
+ groupPolicy: "disabled",
807
+ groupAllowFrom: normalizeAllowEntries(conversationPolicy.groupAllowFrom),
808
+ };
809
+ }
810
+
811
+ const normalizedGroupAllowFrom = normalizeAllowEntries(conversationPolicy.groupAllowFrom);
812
+ const decision = evaluateSenderGroupAccess({
813
+ providerConfigPresent: true,
814
+ configuredGroupPolicy: conversationPolicy.groupPolicy,
815
+ defaultGroupPolicy: resolveDefaultGroupPolicy(params.cfg),
816
+ groupAllowFrom: normalizedGroupAllowFrom,
817
+ senderId: normalizeAllowEntry(params.senderId),
818
+ isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(normalizeAllowEntry(senderId)),
819
+ });
820
+
821
+ return {
822
+ allowed: decision.allowed,
823
+ reason: decision.reason,
824
+ groupPolicy: decision.groupPolicy,
825
+ groupAllowFrom: normalizedGroupAllowFrom,
826
+ };
827
+ }
828
+
829
+ export async function handleMixinMessage(params: {
830
+ cfg: OpenClawConfig;
831
+ accountId: string;
832
+ msg: MixinInboundMessage;
833
+ isDirect: boolean;
834
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
835
+ }): Promise<void> {
836
+ const { cfg, accountId, msg, isDirect, log } = params;
837
+ const rt = getMixinRuntime();
838
+
839
+ if (isProcessed(msg.messageId)) {
840
+ return;
841
+ }
842
+
843
+ const config = getAccountConfig(cfg, accountId);
844
+
845
+ if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
846
+ log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
847
+ try {
848
+ const decrypted = decryptMixinMessage(
849
+ msg.data,
850
+ config.sessionPrivateKey!,
851
+ config.sessionId!,
852
+ );
853
+ if (!decrypted) {
854
+ log.error(`[mixin] decryption failed for ${msg.messageId}`);
855
+ markProcessed(msg.messageId);
856
+ return;
857
+ }
858
+ log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
859
+ msg.data = Buffer.from(decrypted).toString("base64");
860
+ msg.category = "PLAIN_TEXT";
861
+ } catch (err) {
862
+ log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
863
+ markProcessed(msg.messageId);
864
+ return;
865
+ }
866
+ }
867
+
868
+ const isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
869
+ const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
870
+
871
+ if (!isTextMessage && !isAttachmentMessage) {
872
+ log.info(`[mixin] skip non-text message: ${msg.category}`);
873
+ return;
874
+ }
875
+
876
+ let text = decodeContent(msg.category, msg.data).trim();
877
+ let mediaPayload: AgentMediaPayload | undefined;
878
+ if (isAttachmentMessage) {
879
+ const resolved = await resolveInboundAttachment({ rt, config, msg, log });
880
+ text = resolved.text.trim();
881
+ mediaPayload = resolved.mediaPayload;
882
+ }
883
+ log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
884
+
885
+ if (!text) {
886
+ return;
887
+ }
888
+
889
+ const conversationPolicy = isDirect
890
+ ? null
891
+ : resolveConversationPolicy(cfg, accountId, msg.conversationId);
892
+
893
+ if (
894
+ !isDirect &&
895
+ conversationPolicy &&
896
+ !(isAttachmentMessage && conversationPolicy.mediaBypassMention) &&
897
+ !shouldPassGroupFilter({
898
+ ...config,
899
+ requireMentionInGroup: conversationPolicy.requireMention,
900
+ }, text)
901
+ ) {
902
+ log.info(`[mixin] group message filtered: ${msg.messageId}`);
903
+ return;
904
+ }
905
+
906
+ const effectiveAllowFrom = await readEffectiveAllowFrom(rt, accountId, config.allowFrom, log);
907
+ const normalizedUserId = normalizeAllowEntry(msg.userId);
908
+ const dmPolicy = config.dmPolicy ?? "pairing";
909
+ const groupAccess = isDirect
910
+ ? null
911
+ : evaluateMixinGroupAccess({
912
+ cfg,
913
+ config,
914
+ accountId,
915
+ conversationId: msg.conversationId,
916
+ senderId: msg.userId,
917
+ });
918
+ const groupPairingAuthorized = isDirect
919
+ ? false
920
+ : effectiveAllowFrom.has(normalizeAllowEntry(buildGroupScopedPairingId({
921
+ conversationId: msg.conversationId,
922
+ })));
923
+ const isAuthorized = isDirect
924
+ ? dmPolicy === "open" || effectiveAllowFrom.has(normalizedUserId)
925
+ : groupAccess?.allowed === true || groupPairingAuthorized;
926
+
927
+ if (!isAuthorized) {
928
+ if (!isDirect && isMixinGroupAuthCommand(text)) {
929
+ const now = Date.now();
930
+ const lastNotified = unauthNotifiedGroups.get(msg.conversationId) ?? 0;
931
+ const shouldNotify = lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL;
932
+ if (!shouldNotify) {
933
+ markProcessed(msg.messageId);
934
+ return;
935
+ }
936
+ pruneUnauthNotifiedGroups(now);
937
+ unauthNotifiedGroups.set(msg.conversationId, now);
938
+ const { code, created } = await rt.channel.pairing.upsertPairingRequest({
939
+ channel: "mixin",
940
+ id: buildGroupScopedPairingId({
941
+ conversationId: msg.conversationId,
942
+ }),
943
+ accountId,
944
+ meta: buildGroupScopedPairingMeta({
945
+ conversationId: msg.conversationId,
946
+ userId: msg.userId,
947
+ }),
948
+ });
949
+ markProcessed(msg.messageId);
950
+ await sendTextMessage(
951
+ cfg,
952
+ accountId,
953
+ msg.conversationId,
954
+ undefined,
955
+ formatMixinGroupAuthReply({
956
+ code,
957
+ created,
958
+ accountId,
959
+ conversationId: msg.conversationId,
960
+ }),
961
+ log,
962
+ );
963
+ return;
964
+ }
965
+ if (isDirect) {
966
+ log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
967
+ } else {
968
+ log.warn(
969
+ `[mixin] group sender ${msg.userId} blocked: conversationId=${msg.conversationId}, groupPolicy=${groupAccess?.groupPolicy ?? "unknown"}, reason=${groupAccess?.reason ?? "unknown"}`,
970
+ );
971
+ }
972
+ markProcessed(msg.messageId);
973
+ if (isDirect) {
974
+ await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
975
+ }
976
+ return;
977
+ }
978
+
979
+ markProcessed(msg.messageId);
980
+
981
+ if (isOutboxCommand(text)) {
982
+ if (isOutboxPurgeInvalidCommand(text)) {
983
+ const result = await purgePermanentInvalidOutboxEntries();
984
+ const recipientId = isDirect ? msg.userId : undefined;
985
+ const replyText = result.removed > 0
986
+ ? `Removed ${result.removed} invalid outbox entr${result.removed === 1 ? "y" : "ies"}.\n${result.removedJobIds.map((jobId) => `- ${jobId}`).join("\n")}`
987
+ : "No invalid outbox entries found.";
988
+ await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, replyText, log);
989
+ return;
990
+ }
991
+
992
+ const status = await getOutboxStatus();
993
+ const replyText = formatOutboxStatus(status);
994
+ const recipientId = isDirect ? msg.userId : undefined;
995
+ await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, replyText, log);
996
+ return;
997
+ }
998
+
999
+ if (isMixinGroupAuthCommand(text)) {
1000
+ const recipientId = isDirect ? msg.userId : undefined;
1001
+ if (isDirect) {
1002
+ await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, "Use /mixin-group-auth in a group chat.", log);
1003
+ return;
1004
+ }
1005
+ const { code, created } = await rt.channel.pairing.upsertPairingRequest({
1006
+ channel: "mixin",
1007
+ id: buildGroupScopedPairingId({
1008
+ conversationId: msg.conversationId,
1009
+ }),
1010
+ accountId,
1011
+ meta: buildGroupScopedPairingMeta({
1012
+ conversationId: msg.conversationId,
1013
+ userId: msg.userId,
1014
+ }),
1015
+ });
1016
+ await sendTextMessage(
1017
+ cfg,
1018
+ accountId,
1019
+ msg.conversationId,
1020
+ recipientId,
1021
+ formatMixinGroupAuthReply({
1022
+ code,
1023
+ created,
1024
+ accountId,
1025
+ conversationId: msg.conversationId,
1026
+ }),
1027
+ log,
1028
+ );
1029
+ return;
1030
+ }
1031
+
1032
+ if (isMixinGroupApproveCommand(text)) {
1033
+ const code = parseMixinGroupApproveCode(text);
1034
+ const recipientId = isDirect ? msg.userId : undefined;
1035
+ if (!code) {
1036
+ await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, "Usage: openclaw pairing approve mixin <code>", log);
1037
+ return;
1038
+ }
1039
+ await sendTextMessage(
1040
+ cfg,
1041
+ accountId,
1042
+ msg.conversationId,
1043
+ recipientId,
1044
+ [
1045
+ "Group auth approval must be done in the OpenClaw terminal.",
1046
+ "",
1047
+ accountId === "default"
1048
+ ? `Run: openclaw pairing approve mixin ${code}`
1049
+ : `Run: openclaw pairing approve --account ${accountId} mixin ${code}`,
1050
+ ].join("\n"),
1051
+ log,
1052
+ );
1053
+ return;
1054
+ }
1055
+
1056
+ if (isCollectStatusCommand(text)) {
1057
+ const orderId = parseCollectStatusCommand(text);
1058
+ if (orderId) {
1059
+ await refreshMixpayOrderStatus({ cfg, accountId, orderId });
1060
+ }
1061
+ const replyText = orderId ? await getMixpayOrderStatusText(orderId) : "Usage: /collect status <orderId>";
1062
+ const recipientId = isDirect ? msg.userId : undefined;
1063
+ await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, replyText, log);
1064
+ return;
1065
+ }
1066
+
1067
+ if (isCollectRecentCommand(text)) {
1068
+ const recipientId = isDirect ? msg.userId : undefined;
1069
+ const replyText = await getRecentMixpayOrdersText({
1070
+ accountId,
1071
+ conversationId: msg.conversationId,
1072
+ limit: parseCollectRecentLimit(text),
1073
+ });
1074
+ await sendTextMessage(cfg, accountId, msg.conversationId, recipientId, replyText, log);
1075
+ return;
1076
+ }
1077
+
1078
+ const peerId = isDirect ? msg.userId : msg.conversationId;
1079
+ log.info(`[mixin] resolving route: channel=mixin, accountId=${accountId}, peer.kind=${isDirect ? "direct" : "group"}, peer.id=${peerId}`);
1080
+
1081
+ const route = rt.channel.routing.resolveAgentRoute({
1082
+ cfg,
1083
+ channel: "mixin",
1084
+ accountId,
1085
+ peer: {
1086
+ kind: isDirect ? "direct" : "group",
1087
+ id: peerId,
1088
+ },
1089
+ });
1090
+
1091
+ log.info(`[mixin] route result: ${route ? "FOUND" : "NULL"} - agentId=${route?.agentId ?? "N/A"}`);
1092
+
1093
+ if (!route) {
1094
+ log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
1095
+ return;
1096
+ }
1097
+
1098
+ const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(text, cfg);
1099
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
1100
+ const senderAllowedForCommands = useAccessGroups
1101
+ ? isDirect
1102
+ ? effectiveAllowFrom.has(normalizedUserId)
1103
+ : groupAccess?.allowed === true || groupPairingAuthorized
1104
+ : true;
1105
+
1106
+ const commandAuthorized = shouldComputeCommandAuthorized
1107
+ ? rt.channel.commands.resolveCommandAuthorizedFromAuthorizers({
1108
+ useAccessGroups,
1109
+ authorizers: [
1110
+ {
1111
+ configured: isDirect ? effectiveAllowFrom.size > 0 : (groupAccess?.groupAllowFrom.length ?? 0) > 0,
1112
+ allowed: senderAllowedForCommands,
1113
+ },
1114
+ ],
1115
+ })
1116
+ : undefined;
1117
+
1118
+ const senderName = await resolveSenderName({
1119
+ accountId,
1120
+ config,
1121
+ userId: msg.userId,
1122
+ log,
1123
+ });
1124
+ const botName = await resolveBotName({
1125
+ accountId,
1126
+ config,
1127
+ log,
1128
+ });
1129
+ const groupName = isDirect
1130
+ ? ""
1131
+ : await resolveGroupName({
1132
+ accountId,
1133
+ config,
1134
+ conversationId: msg.conversationId,
1135
+ log,
1136
+ });
1137
+ const conversationLabel = isDirect
1138
+ ? clampSessionLabel(`${botName}-${senderName || msg.userId}`)
1139
+ : clampSessionLabel(`${botName}-${groupName || msg.conversationId}`);
1140
+
1141
+ const ctx = rt.channel.reply.finalizeInboundContext({
1142
+ Body: text,
1143
+ RawBody: text,
1144
+ CommandBody: text,
1145
+ From: isDirect ? msg.userId : msg.conversationId,
1146
+ SenderId: msg.userId,
1147
+ SenderName: senderName,
1148
+ SessionKey: route.sessionKey,
1149
+ AccountId: accountId,
1150
+ ChatType: isDirect ? "direct" : "group",
1151
+ ConversationLabel: conversationLabel,
1152
+ GroupSubject: isDirect ? undefined : groupName || msg.conversationId,
1153
+ Provider: "mixin",
1154
+ Surface: "mixin",
1155
+ MessageSid: msg.messageId,
1156
+ CommandAuthorized: commandAuthorized,
1157
+ OriginatingChannel: "mixin",
1158
+ OriginatingTo: isDirect ? msg.userId : msg.conversationId,
1159
+ ...mediaPayload,
1160
+ });
1161
+
1162
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
1163
+ agentId: route.agentId,
1164
+ });
1165
+ await rt.channel.session.recordInboundSession({
1166
+ storePath,
1167
+ sessionKey: route.sessionKey,
1168
+ ctx,
1169
+ onRecordError: (err: unknown) => {
1170
+ log.error("[mixin] session record error", err);
1171
+ },
1172
+ });
1173
+ await updateSessionPresentation({
1174
+ storePath,
1175
+ sessionKey: route.sessionKey,
1176
+ label: conversationLabel,
1177
+ displayName: conversationLabel,
1178
+ log,
1179
+ });
1180
+
1181
+ log.info(`[mixin] dispatching ${msg.messageId} from ${msg.userId}`);
1182
+
1183
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1184
+ ctx,
1185
+ cfg,
1186
+ dispatcherOptions: {
1187
+ deliver: async (payload) => {
1188
+ const replyText = payload.text ?? "";
1189
+ if (!replyText) {
1190
+ return;
1191
+ }
1192
+ const recipientId = isDirect ? msg.userId : undefined;
1193
+ await deliverMixinReply({
1194
+ cfg,
1195
+ accountId,
1196
+ conversationId: msg.conversationId,
1197
+ recipientId,
1198
+ creatorId: msg.userId,
1199
+ text: replyText,
1200
+ log,
1201
+ });
1202
+ },
1203
+ },
1204
+ });
1205
+ }