@invago/mixin 1.0.7 → 1.0.8

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,18 +1,25 @@
1
- import type { 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 { 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";
2
12
  import { getMixinRuntime } from "./runtime.js";
3
13
  import {
14
+ sendAudioMessage,
4
15
  getOutboxStatus,
5
16
  purgePermanentInvalidOutboxEntries,
17
+ sendFileMessage,
6
18
  sendButtonGroupMessage,
7
19
  sendCardMessage,
8
20
  sendPostMessage,
9
21
  sendTextMessage,
10
22
  } from "./send-service.js";
11
- import { getAccountConfig } from "./config.js";
12
- import type { MixinAccountConfig } from "./config-schema.js";
13
-
14
- import { decryptMixinMessage } from "./crypto.js";
15
- import { buildMixinReplyPlan } from "./reply-format.js";
16
23
 
17
24
  export interface MixinInboundMessage {
18
25
  conversationId: string;
@@ -27,8 +34,18 @@ export interface MixinInboundMessage {
27
34
  const processedMessages = new Set<string>();
28
35
  const MAX_DEDUP_SIZE = 2000;
29
36
  const unauthNotifiedUsers = new Map<string, number>();
37
+ const loggedAllowFromAccounts = new Set<string>();
30
38
  const UNAUTH_NOTIFY_INTERVAL = 20 * 60 * 1000;
31
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
+ };
32
49
 
33
50
  function isProcessed(messageId: string): boolean {
34
51
  return processedMessages.has(messageId);
@@ -37,7 +54,9 @@ function isProcessed(messageId: string): boolean {
37
54
  function markProcessed(messageId: string): void {
38
55
  if (processedMessages.size >= MAX_DEDUP_SIZE) {
39
56
  const first = processedMessages.values().next().value;
40
- if (first) processedMessages.delete(first);
57
+ if (first) {
58
+ processedMessages.delete(first);
59
+ }
41
60
  }
42
61
  processedMessages.add(messageId);
43
62
  }
@@ -69,14 +88,122 @@ function decodeContent(category: string, data: string): string {
69
88
  return `[${category}]`;
70
89
  }
71
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
+
72
201
  function shouldPassGroupFilter(config: MixinAccountConfig, text: string): boolean {
73
- if (!config.requireMentionInGroup) return true;
202
+ if (!config.requireMentionInGroup) {
203
+ return true;
204
+ }
74
205
  const lower = text.toLowerCase();
75
- return (
76
- lower.includes("?") ||
77
- lower.includes("?") ||
78
- /帮|请|分析|总结|help/i.test(lower)
79
- );
206
+ return lower.includes("?") || /帮我|请|分析|总结|help/i.test(lower);
80
207
  }
81
208
 
82
209
  function isOutboxCommand(text: string): boolean {
@@ -105,6 +232,55 @@ function formatOutboxStatus(status: Awaited<ReturnType<typeof getOutboxStatus>>)
105
232
  return lines.join("\n");
106
233
  }
107
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
+
108
284
  async function deliverMixinReply(params: {
109
285
  cfg: OpenClawConfig;
110
286
  accountId: string;
@@ -130,6 +306,16 @@ async function deliverMixinReply(params: {
130
306
  return;
131
307
  }
132
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
+
133
319
  if (plan.kind === "buttons") {
134
320
  if (plan.intro) {
135
321
  await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.intro, log);
@@ -141,6 +327,65 @@ async function deliverMixinReply(params: {
141
327
  await sendCardMessage(cfg, accountId, conversationId, recipientId, plan.card, log);
142
328
  }
143
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
+
144
389
  export async function handleMixinMessage(params: {
145
390
  cfg: OpenClawConfig;
146
391
  accountId: string;
@@ -151,73 +396,75 @@ export async function handleMixinMessage(params: {
151
396
  const { cfg, accountId, msg, isDirect, log } = params;
152
397
  const rt = getMixinRuntime();
153
398
 
154
- // 立即检查是否已处理,防止并发
155
- if (isProcessed(msg.messageId)) return;
156
-
157
- const config = getAccountConfig(cfg, accountId);
158
-
159
- // 处理加密消息
160
- if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
161
- log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
162
- try {
163
- const decrypted = decryptMixinMessage(
164
- msg.data,
165
- config.sessionPrivateKey!,
166
- config.sessionId!
167
- );
168
- if (decrypted) {
169
- log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
170
- msg.data = Buffer.from(decrypted).toString("base64");
171
- msg.category = "PLAIN_TEXT";
172
- } else {
173
- log.error(`[mixin] decryption failed for ${msg.messageId}`);
174
- markProcessed(msg.messageId);
175
- return;
176
- }
177
- } catch (err) {
178
- log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
179
- markProcessed(msg.messageId);
180
- return;
181
- }
182
- }
183
-
184
- // 检查是否是文本消息
185
- if (!msg.category.startsWith("PLAIN_TEXT") && !msg.category.startsWith("PLAIN_POST")) {
186
- log.info(`[mixin] skip non-text message: ${msg.category}`);
187
- return;
188
- }
189
-
190
- const text = decodeContent(msg.category, msg.data).trim();
191
- log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
192
-
193
- if (!text) return;
194
-
195
- // 群组消息过滤:只有包含关键词的消息才会被处理
196
- if (!isDirect && !shouldPassGroupFilter(config, text)) {
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)) {
197
450
  log.info(`[mixin] group message filtered: ${msg.messageId}`);
198
451
  return;
199
452
  }
200
453
 
201
- // allowlist 检查:只处理白名单中的用户
202
- if (!config.allowFrom.includes(msg.userId)) {
203
- log.warn(`[mixin] user ${msg.userId} not in allowlist`);
204
- markProcessed(msg.messageId);
205
-
206
- // 只在首次消息时回复,20分钟内不重复回复
207
- const now = Date.now();
208
- const lastNotified = unauthNotifiedUsers.get(msg.userId) ?? 0;
209
-
210
- if (lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL) {
211
- pruneUnauthNotifiedUsers(now);
212
- unauthNotifiedUsers.set(msg.userId, now);
213
- const msgBody = `⚠️ 请等待管理员认证\n\n您的 Mixin UUID: ${msg.userId}\n\n请将此UUID添加到 allowFrom 列表中完成认证`;
214
- sendTextMessage(cfg, accountId, msg.conversationId, msg.userId, msgBody, log).catch(() => {});
215
- }
216
-
217
- return;
218
- }
219
-
220
- // 标记为已处理
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
+
221
468
  markProcessed(msg.messageId);
222
469
 
223
470
  if (isOutboxCommand(text)) {
@@ -238,99 +485,92 @@ if (!config.allowFrom.includes(msg.userId)) {
238
485
  return;
239
486
  }
240
487
 
241
- // 解析消息路由
242
- const peerId = isDirect ? msg.userId : msg.conversationId;
243
- log.info(`[mixin] resolving route: channel=mixin, accountId=${accountId}, peer.kind=${isDirect ? "direct" : "group"}, peer.id=${peerId}`);
244
-
245
- const route = rt.channel.routing.resolveAgentRoute({
246
- cfg,
247
- channel: "mixin",
248
- accountId,
249
- peer: {
250
- kind: isDirect ? "direct" : "group",
251
- id: peerId,
252
- },
253
- });
254
-
255
- log.info(`[mixin] route result: ${route ? "FOUND" : "NULL"} - agentId=${route?.agentId ?? "N/A"}`);
256
-
257
- if (!route) {
258
- log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
259
- return;
260
- }
261
-
262
- // 创建上下文
263
- const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(
264
- text,
265
- cfg,
266
- );
267
-
268
- const useAccessGroups = cfg.commands?.useAccessGroups !== false;
269
- const commandAllowFrom = config.allowFrom;
270
-
271
- const senderAllowedForCommands = useAccessGroups
272
- ? config.allowFrom.includes(msg.userId)
273
- : true;
274
-
275
- const commandAuthorized = shouldComputeCommandAuthorized
276
- ? rt.channel.commands.resolveCommandAuthorizedFromAuthorizers({
277
- useAccessGroups,
278
- authorizers: [
279
- {
280
- configured: commandAllowFrom.length > 0,
281
- allowed: senderAllowedForCommands,
282
- },
283
- ],
284
- })
285
- : undefined;
286
-
287
- const ctx = rt.channel.reply.finalizeInboundContext({
288
- Body: text,
289
- RawBody: text,
290
- CommandBody: text,
291
- From: isDirect ? msg.userId : msg.conversationId,
292
- SessionKey: route.sessionKey,
293
- AccountId: accountId,
294
- ChatType: isDirect ? "direct" : "group",
295
- Provider: "mixin",
296
- Surface: "mixin",
297
- MessageSid: msg.messageId,
298
- CommandAuthorized: commandAuthorized,
299
- });
300
-
301
- // 记录会话
302
- const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
303
- agentId: route.agentId,
304
- });
305
- await rt.channel.session.recordInboundSession({
306
- storePath,
307
- sessionKey: route.sessionKey,
308
- ctx,
309
- onRecordError: (err: unknown) => {
310
- log.error("[mixin] session record error", err);
311
- },
312
- });
313
-
314
- // 分发消息
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
+
315
553
  log.info(`[mixin] dispatching ${msg.messageId} from ${msg.userId}`);
316
554
 
317
555
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
318
556
  ctx,
319
557
  cfg,
320
558
  dispatcherOptions: {
321
- deliver: async (payload) => {
322
- const replyText = payload.text ?? "";
323
- if (!replyText) return;
324
- const recipientId = isDirect ? msg.userId : undefined;
325
- await deliverMixinReply({
326
- cfg,
327
- accountId,
328
- conversationId: msg.conversationId,
329
- recipientId,
330
- text: replyText,
331
- log,
332
- });
333
- },
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
+ },
334
574
  },
335
575
  });
336
576
  }