@invago/mixin 1.0.7 → 1.0.9

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,20 @@
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, evaluateSenderGroupAccess, resolveDefaultGroupPolicy } from "openclaw/plugin-sdk";
6
+ import type { AgentMediaPayload, OpenClawConfig } from "openclaw/plugin-sdk";
7
+ import { getAccountConfig, resolveConversationPolicy } 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 { buildMixinOutboundPlanFromReplyText, executeMixinOutboundPlan } from "./outbound-plan.js";
2
12
  import { getMixinRuntime } from "./runtime.js";
3
13
  import {
4
14
  getOutboxStatus,
5
15
  purgePermanentInvalidOutboxEntries,
6
- sendButtonGroupMessage,
7
- sendCardMessage,
8
- sendPostMessage,
9
16
  sendTextMessage,
10
17
  } 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
18
 
17
19
  export interface MixinInboundMessage {
18
20
  conversationId: string;
@@ -27,8 +29,18 @@ export interface MixinInboundMessage {
27
29
  const processedMessages = new Set<string>();
28
30
  const MAX_DEDUP_SIZE = 2000;
29
31
  const unauthNotifiedUsers = new Map<string, number>();
32
+ const loggedAllowFromAccounts = new Set<string>();
30
33
  const UNAUTH_NOTIFY_INTERVAL = 20 * 60 * 1000;
31
34
  const MAX_UNAUTH_NOTIFY_USERS = 1000;
35
+ const INBOUND_MEDIA_MAX_BYTES = 30 * 1024 * 1024;
36
+
37
+ type MixinAttachmentRequest = {
38
+ attachmentId: string;
39
+ mimeType?: string;
40
+ size?: number;
41
+ fileName?: string;
42
+ duration?: number;
43
+ };
32
44
 
33
45
  function isProcessed(messageId: string): boolean {
34
46
  return processedMessages.has(messageId);
@@ -37,7 +49,9 @@ function isProcessed(messageId: string): boolean {
37
49
  function markProcessed(messageId: string): void {
38
50
  if (processedMessages.size >= MAX_DEDUP_SIZE) {
39
51
  const first = processedMessages.values().next().value;
40
- if (first) processedMessages.delete(first);
52
+ if (first) {
53
+ processedMessages.delete(first);
54
+ }
41
55
  }
42
56
  processedMessages.add(messageId);
43
57
  }
@@ -69,14 +83,131 @@ function decodeContent(category: string, data: string): string {
69
83
  return `[${category}]`;
70
84
  }
71
85
 
86
+ function buildClient(config: MixinAccountConfig) {
87
+ return MixinApi({
88
+ keystore: {
89
+ app_id: config.appId!,
90
+ session_id: config.sessionId!,
91
+ server_public_key: config.serverPublicKey!,
92
+ session_private_key: config.sessionPrivateKey!,
93
+ },
94
+ requestConfig: buildRequestConfig(config.proxy),
95
+ });
96
+ }
97
+
98
+ function resolveInboundMediaMaxBytes(config: MixinAccountConfig): number {
99
+ const mediaMaxMb = config.mediaMaxMb;
100
+ if (typeof mediaMaxMb === "number" && Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
101
+ return Math.max(1, Math.floor(mediaMaxMb * 1024 * 1024));
102
+ }
103
+ return INBOUND_MEDIA_MAX_BYTES;
104
+ }
105
+
106
+ function parseInboundAttachmentRequest(category: string, data: string): MixinAttachmentRequest | null {
107
+ if (category !== "PLAIN_DATA" && category !== "PLAIN_AUDIO") {
108
+ return null;
109
+ }
110
+
111
+ try {
112
+ const decoded = Buffer.from(data, "base64").toString("utf-8");
113
+ const parsed = JSON.parse(decoded) as {
114
+ attachment_id?: unknown;
115
+ mime_type?: unknown;
116
+ size?: unknown;
117
+ name?: unknown;
118
+ duration?: unknown;
119
+ };
120
+
121
+ if (typeof parsed.attachment_id !== "string" || !parsed.attachment_id.trim()) {
122
+ return null;
123
+ }
124
+
125
+ return {
126
+ attachmentId: parsed.attachment_id.trim(),
127
+ mimeType: typeof parsed.mime_type === "string" ? parsed.mime_type.trim() || undefined : undefined,
128
+ size: typeof parsed.size === "number" && Number.isFinite(parsed.size) ? parsed.size : undefined,
129
+ fileName: typeof parsed.name === "string" ? parsed.name.trim() || undefined : undefined,
130
+ duration: typeof parsed.duration === "number" && Number.isFinite(parsed.duration) ? parsed.duration : undefined,
131
+ };
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function formatInboundAttachmentText(category: string, payload: MixinAttachmentRequest): string {
138
+ if (category === "PLAIN_AUDIO") {
139
+ const details = [
140
+ payload.fileName,
141
+ payload.mimeType,
142
+ typeof payload.duration === "number" ? `${payload.duration}s` : undefined,
143
+ typeof payload.size === "number" ? `${payload.size} bytes` : undefined,
144
+ ].filter(Boolean);
145
+ return details.length > 0 ? `[Mixin audio] ${details.join(" | ")}` : "[Mixin audio]";
146
+ }
147
+
148
+ const details = [
149
+ payload.fileName,
150
+ payload.mimeType,
151
+ typeof payload.size === "number" ? `${payload.size} bytes` : undefined,
152
+ ].filter(Boolean);
153
+ return details.length > 0 ? `[Mixin file] ${details.join(" | ")}` : "[Mixin file]";
154
+ }
155
+
156
+ async function resolveInboundAttachment(params: {
157
+ rt: ReturnType<typeof getMixinRuntime>;
158
+ config: MixinAccountConfig;
159
+ msg: MixinInboundMessage;
160
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
161
+ }): Promise<{ text: string; mediaPayload?: AgentMediaPayload }> {
162
+ const payload = parseInboundAttachmentRequest(params.msg.category, params.msg.data);
163
+ if (!payload) {
164
+ return {
165
+ text: `[${params.msg.category}]`,
166
+ };
167
+ }
168
+
169
+ try {
170
+ const client = buildClient(params.config);
171
+ const maxBytes = resolveInboundMediaMaxBytes(params.config);
172
+ const attachment = await client.attachment.fetch(payload.attachmentId);
173
+ const fetched = await params.rt.channel.media.fetchRemoteMedia({
174
+ url: attachment.view_url,
175
+ filePathHint: payload.fileName,
176
+ maxBytes,
177
+ });
178
+ const saved = await params.rt.channel.media.saveMediaBuffer(
179
+ fetched.buffer,
180
+ payload.mimeType ?? fetched.contentType,
181
+ "mixin",
182
+ maxBytes,
183
+ payload.fileName ?? fetched.fileName,
184
+ );
185
+
186
+ return {
187
+ text: formatInboundAttachmentText(params.msg.category, payload),
188
+ mediaPayload: buildAgentMediaPayload([
189
+ {
190
+ path: saved.path,
191
+ contentType: saved.contentType ?? payload.mimeType ?? fetched.contentType,
192
+ },
193
+ ]),
194
+ };
195
+ } catch (err) {
196
+ params.log.warn(
197
+ `[mixin] failed to resolve inbound attachment: messageId=${params.msg.messageId}, category=${params.msg.category}, error=${err instanceof Error ? err.message : String(err)}`,
198
+ );
199
+ return {
200
+ text: formatInboundAttachmentText(params.msg.category, payload),
201
+ };
202
+ }
203
+ }
204
+
72
205
  function shouldPassGroupFilter(config: MixinAccountConfig, text: string): boolean {
73
- if (!config.requireMentionInGroup) return true;
206
+ if (!config.requireMentionInGroup) {
207
+ return true;
208
+ }
74
209
  const lower = text.toLowerCase();
75
- return (
76
- lower.includes("?") ||
77
- lower.includes("?") ||
78
- /帮|请|分析|总结|help/i.test(lower)
79
- );
210
+ return lower.includes("?") || /帮我|请|分析|总结|help/i.test(lower);
80
211
  }
81
212
 
82
213
  function isOutboxCommand(text: string): boolean {
@@ -105,6 +236,59 @@ function formatOutboxStatus(status: Awaited<ReturnType<typeof getOutboxStatus>>)
105
236
  return lines.join("\n");
106
237
  }
107
238
 
239
+ function normalizeAllowEntry(entry: string): string {
240
+ return entry.trim().toLowerCase();
241
+ }
242
+
243
+ function normalizeAllowEntries(entries: string[] | undefined): string[] {
244
+ return (entries ?? []).map(normalizeAllowEntry).filter(Boolean);
245
+ }
246
+
247
+ function resolveMixinAllowFromPaths(
248
+ rt: ReturnType<typeof getMixinRuntime>,
249
+ accountId: string,
250
+ ): string[] {
251
+ const oauthOverride = process.env.OPENCLAW_OAUTH_DIR?.trim();
252
+ const oauthDir = oauthOverride
253
+ ? path.resolve(oauthOverride)
254
+ : path.join(rt.state.resolveStateDir(process.env, os.homedir), "credentials");
255
+ const normalizedAccountId = accountId.trim().toLowerCase();
256
+ const paths = [path.join(oauthDir, "mixin-allowFrom.json")];
257
+ if (normalizedAccountId) {
258
+ paths.unshift(path.join(oauthDir, `mixin-${normalizedAccountId}-allowFrom.json`));
259
+ }
260
+ return Array.from(new Set(paths));
261
+ }
262
+
263
+ async function readAllowFromFile(filePath: string): Promise<string[]> {
264
+ try {
265
+ const raw = await fs.readFile(filePath, "utf8");
266
+ const parsed = JSON.parse(raw) as { allowFrom?: unknown };
267
+ return Array.isArray(parsed.allowFrom)
268
+ ? parsed.allowFrom.map((entry) => String(entry)).map(normalizeAllowEntry).filter(Boolean)
269
+ : [];
270
+ } catch {
271
+ return [];
272
+ }
273
+ }
274
+
275
+ async function readEffectiveAllowFrom(
276
+ rt: ReturnType<typeof getMixinRuntime>,
277
+ accountId: string,
278
+ configAllowFrom: string[],
279
+ log?: { info: (m: string) => void },
280
+ ): Promise<Set<string>> {
281
+ const runtimeAllowFrom = await rt.channel.pairing.readAllowFromStore("mixin", undefined, accountId).catch(() => []);
282
+ const filePaths = resolveMixinAllowFromPaths(rt, accountId);
283
+ if (!loggedAllowFromAccounts.has(accountId)) {
284
+ log?.info(`[mixin] allow-from paths: accountId=${accountId}, paths=${filePaths.join(", ")}`);
285
+ loggedAllowFromAccounts.add(accountId);
286
+ }
287
+ const fileEntries = await Promise.all(filePaths.map((filePath) => readAllowFromFile(filePath)));
288
+ const fileAllowFrom = fileEntries.flat();
289
+ return new Set([...configAllowFrom, ...runtimeAllowFrom, ...fileAllowFrom].map(normalizeAllowEntry).filter(Boolean));
290
+ }
291
+
108
292
  async function deliverMixinReply(params: {
109
293
  cfg: OpenClawConfig;
110
294
  accountId: string;
@@ -114,31 +298,120 @@ async function deliverMixinReply(params: {
114
298
  log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
115
299
  }): Promise<void> {
116
300
  const { cfg, accountId, conversationId, recipientId, text, log } = params;
117
- const plan = buildMixinReplyPlan(text);
118
-
119
- if (!plan) {
301
+ const plan = buildMixinOutboundPlanFromReplyText(text);
302
+ if (plan.steps.length === 0) {
120
303
  return;
121
304
  }
305
+ for (const warning of plan.warnings) {
306
+ log.warn(`[mixin] outbound plan warning: ${warning}`);
307
+ }
308
+ await executeMixinOutboundPlan({
309
+ cfg,
310
+ accountId,
311
+ conversationId,
312
+ recipientId,
313
+ steps: plan.steps,
314
+ log,
315
+ });
316
+ }
317
+
318
+ async function handleUnauthorizedDirectMessage(params: {
319
+ rt: ReturnType<typeof getMixinRuntime>;
320
+ cfg: OpenClawConfig;
321
+ accountId: string;
322
+ config: MixinAccountConfig;
323
+ msg: MixinInboundMessage;
324
+ log: { info: (m: string) => void; warn: (m: string) => void; error: (m: string, e?: unknown) => void };
325
+ }): Promise<void> {
326
+ const { rt, cfg, accountId, config, msg, log } = params;
327
+ const dmPolicy = config.dmPolicy ?? "pairing";
122
328
 
123
- if (plan.kind === "text") {
124
- await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
329
+ if (dmPolicy === "disabled") {
125
330
  return;
126
331
  }
127
332
 
128
- if (plan.kind === "post") {
129
- await sendPostMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
333
+ const now = Date.now();
334
+ const lastNotified = unauthNotifiedUsers.get(msg.userId) ?? 0;
335
+ const shouldNotify = lastNotified === 0 || now - lastNotified > UNAUTH_NOTIFY_INTERVAL;
336
+
337
+ if (!shouldNotify) {
130
338
  return;
131
339
  }
132
340
 
133
- if (plan.kind === "buttons") {
134
- if (plan.intro) {
135
- await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.intro, log);
341
+ pruneUnauthNotifiedUsers(now);
342
+ unauthNotifiedUsers.set(msg.userId, now);
343
+
344
+ if (dmPolicy === "pairing") {
345
+ try {
346
+ const { code, created } = await rt.channel.pairing.upsertPairingRequest({
347
+ channel: "mixin",
348
+ id: msg.userId,
349
+ accountId,
350
+ meta: {
351
+ conversationId: msg.conversationId,
352
+ },
353
+ });
354
+
355
+ if (created && code) {
356
+ const reply = rt.channel.pairing.buildPairingReply({
357
+ channel: "mixin",
358
+ idLine: `Your Mixin UUID: ${msg.userId}`,
359
+ code,
360
+ });
361
+ await sendTextMessage(cfg, accountId, msg.conversationId, msg.userId, reply, log);
362
+ }
363
+ } catch (err) {
364
+ log.error(`[mixin] pairing reply failed for ${msg.userId}`, err);
136
365
  }
137
- await sendButtonGroupMessage(cfg, accountId, conversationId, recipientId, plan.buttons, log);
138
366
  return;
139
367
  }
140
368
 
141
- await sendCardMessage(cfg, accountId, conversationId, recipientId, plan.card, log);
369
+ if (dmPolicy === "allowlist") {
370
+ const reply = config.allowFrom.length > 0
371
+ ? `OpenClaw: access not configured.\n\nYour Mixin UUID: ${msg.userId}\n\nAsk the bot owner to add your Mixin UUID to channels.mixin.allowFrom.`
372
+ : `OpenClaw: access not configured.\n\nYour Mixin UUID: ${msg.userId}\n\nAsk the bot owner to add your Mixin UUID to channels.mixin.allowFrom.`;
373
+ await sendTextMessage(cfg, accountId, msg.conversationId, msg.userId, reply, log);
374
+ }
375
+ }
376
+
377
+ function evaluateMixinGroupAccess(params: {
378
+ cfg: OpenClawConfig;
379
+ config: MixinAccountConfig;
380
+ accountId: string;
381
+ conversationId: string;
382
+ senderId: string;
383
+ }): {
384
+ allowed: boolean;
385
+ reason: string;
386
+ groupPolicy: "open" | "disabled" | "allowlist";
387
+ groupAllowFrom: string[];
388
+ } {
389
+ const conversationPolicy = resolveConversationPolicy(params.cfg, params.accountId, params.conversationId);
390
+ if (!conversationPolicy.enabled) {
391
+ return {
392
+ allowed: false,
393
+ reason: "conversation disabled",
394
+ groupPolicy: "disabled",
395
+ groupAllowFrom: normalizeAllowEntries(conversationPolicy.groupAllowFrom),
396
+ };
397
+ }
398
+
399
+ const normalizedGroupAllowFrom = normalizeAllowEntries(conversationPolicy.groupAllowFrom);
400
+ const decision = evaluateSenderGroupAccess({
401
+ providerConfigPresent: true,
402
+ configuredGroupPolicy: conversationPolicy.groupPolicy,
403
+ defaultGroupPolicy: resolveDefaultGroupPolicy(params.cfg),
404
+ groupAllowFrom: normalizedGroupAllowFrom,
405
+ senderId: normalizeAllowEntry(params.senderId),
406
+ isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(normalizeAllowEntry(senderId)),
407
+ });
408
+
409
+ return {
410
+ allowed: decision.allowed,
411
+ reason: decision.reason,
412
+ groupPolicy: decision.groupPolicy,
413
+ groupAllowFrom: normalizedGroupAllowFrom,
414
+ };
142
415
  }
143
416
 
144
417
  export async function handleMixinMessage(params: {
@@ -151,73 +424,104 @@ export async function handleMixinMessage(params: {
151
424
  const { cfg, accountId, msg, isDirect, log } = params;
152
425
  const rt = getMixinRuntime();
153
426
 
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)) {
427
+ if (isProcessed(msg.messageId)) {
428
+ return;
429
+ }
430
+
431
+ const config = getAccountConfig(cfg, accountId);
432
+
433
+ if (msg.category === "ENCRYPTED_TEXT" || msg.category === "ENCRYPTED_POST") {
434
+ log.info(`[mixin] decrypting encrypted message ${msg.messageId}, category=${msg.category}`);
435
+ try {
436
+ const decrypted = decryptMixinMessage(
437
+ msg.data,
438
+ config.sessionPrivateKey!,
439
+ config.sessionId!,
440
+ );
441
+ if (!decrypted) {
442
+ log.error(`[mixin] decryption failed for ${msg.messageId}`);
443
+ markProcessed(msg.messageId);
444
+ return;
445
+ }
446
+ log.info(`[mixin] decryption successful: messageId=${msg.messageId}, length=${decrypted.length}`);
447
+ msg.data = Buffer.from(decrypted).toString("base64");
448
+ msg.category = "PLAIN_TEXT";
449
+ } catch (err) {
450
+ log.error(`[mixin] decryption exception for ${msg.messageId}`, err);
451
+ markProcessed(msg.messageId);
452
+ return;
453
+ }
454
+ }
455
+
456
+ const isTextMessage = msg.category.startsWith("PLAIN_TEXT") || msg.category.startsWith("PLAIN_POST");
457
+ const isAttachmentMessage = msg.category === "PLAIN_DATA" || msg.category === "PLAIN_AUDIO";
458
+
459
+ if (!isTextMessage && !isAttachmentMessage) {
460
+ log.info(`[mixin] skip non-text message: ${msg.category}`);
461
+ return;
462
+ }
463
+
464
+ let text = decodeContent(msg.category, msg.data).trim();
465
+ let mediaPayload: AgentMediaPayload | undefined;
466
+ if (isAttachmentMessage) {
467
+ const resolved = await resolveInboundAttachment({ rt, config, msg, log });
468
+ text = resolved.text.trim();
469
+ mediaPayload = resolved.mediaPayload;
470
+ }
471
+ log.info(`[mixin] decoded text: messageId=${msg.messageId}, category=${msg.category}, length=${text.length}`);
472
+
473
+ if (!text) {
474
+ return;
475
+ }
476
+
477
+ const conversationPolicy = isDirect
478
+ ? null
479
+ : resolveConversationPolicy(cfg, accountId, msg.conversationId);
480
+
481
+ if (
482
+ !isDirect &&
483
+ conversationPolicy &&
484
+ !(isAttachmentMessage && conversationPolicy.mediaBypassMention) &&
485
+ !shouldPassGroupFilter({
486
+ ...config,
487
+ requireMentionInGroup: conversationPolicy.requireMention,
488
+ }, text)
489
+ ) {
197
490
  log.info(`[mixin] group message filtered: ${msg.messageId}`);
198
491
  return;
199
492
  }
200
493
 
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
- // 标记为已处理
494
+ const effectiveAllowFrom = await readEffectiveAllowFrom(rt, accountId, config.allowFrom, log);
495
+ const normalizedUserId = normalizeAllowEntry(msg.userId);
496
+ const dmPolicy = config.dmPolicy ?? "pairing";
497
+ const groupAccess = isDirect
498
+ ? null
499
+ : evaluateMixinGroupAccess({
500
+ cfg,
501
+ config,
502
+ accountId,
503
+ conversationId: msg.conversationId,
504
+ senderId: msg.userId,
505
+ });
506
+ const isAuthorized = isDirect
507
+ ? dmPolicy === "open" || effectiveAllowFrom.has(normalizedUserId)
508
+ : groupAccess?.allowed === true;
509
+
510
+ if (!isAuthorized) {
511
+ if (isDirect) {
512
+ log.warn(`[mixin] user ${msg.userId} not authorized (dmPolicy=${dmPolicy})`);
513
+ } else {
514
+ log.warn(
515
+ `[mixin] group sender ${msg.userId} blocked: conversationId=${msg.conversationId}, groupPolicy=${groupAccess?.groupPolicy ?? "unknown"}, reason=${groupAccess?.reason ?? "unknown"}`,
516
+ );
517
+ }
518
+ markProcessed(msg.messageId);
519
+ if (isDirect) {
520
+ await handleUnauthorizedDirectMessage({ rt, cfg, accountId, config, msg, log });
521
+ }
522
+ return;
523
+ }
524
+
221
525
  markProcessed(msg.messageId);
222
526
 
223
527
  if (isOutboxCommand(text)) {
@@ -238,99 +542,96 @@ if (!config.allowFrom.includes(msg.userId)) {
238
542
  return;
239
543
  }
240
544
 
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
- // 分发消息
545
+ const peerId = isDirect ? msg.userId : msg.conversationId;
546
+ log.info(`[mixin] resolving route: channel=mixin, accountId=${accountId}, peer.kind=${isDirect ? "direct" : "group"}, peer.id=${peerId}`);
547
+
548
+ const route = rt.channel.routing.resolveAgentRoute({
549
+ cfg,
550
+ channel: "mixin",
551
+ accountId,
552
+ peer: {
553
+ kind: isDirect ? "direct" : "group",
554
+ id: peerId,
555
+ },
556
+ });
557
+
558
+ log.info(`[mixin] route result: ${route ? "FOUND" : "NULL"} - agentId=${route?.agentId ?? "N/A"}`);
559
+
560
+ if (!route) {
561
+ log.warn(`[mixin] no agent route for ${msg.userId} (peerId: ${peerId})`);
562
+ return;
563
+ }
564
+
565
+ const shouldComputeCommandAuthorized = rt.channel.commands.shouldComputeCommandAuthorized(text, cfg);
566
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
567
+ const senderAllowedForCommands = useAccessGroups
568
+ ? isDirect
569
+ ? effectiveAllowFrom.has(normalizedUserId)
570
+ : groupAccess?.allowed === true
571
+ : true;
572
+
573
+ const commandAuthorized = shouldComputeCommandAuthorized
574
+ ? rt.channel.commands.resolveCommandAuthorizedFromAuthorizers({
575
+ useAccessGroups,
576
+ authorizers: [
577
+ {
578
+ configured: isDirect ? effectiveAllowFrom.size > 0 : (groupAccess?.groupAllowFrom.length ?? 0) > 0,
579
+ allowed: senderAllowedForCommands,
580
+ },
581
+ ],
582
+ })
583
+ : undefined;
584
+
585
+ const ctx = rt.channel.reply.finalizeInboundContext({
586
+ Body: text,
587
+ RawBody: text,
588
+ CommandBody: text,
589
+ From: isDirect ? msg.userId : msg.conversationId,
590
+ SessionKey: route.sessionKey,
591
+ AccountId: accountId,
592
+ ChatType: isDirect ? "direct" : "group",
593
+ Provider: "mixin",
594
+ Surface: "mixin",
595
+ MessageSid: msg.messageId,
596
+ CommandAuthorized: commandAuthorized,
597
+ OriginatingChannel: "mixin",
598
+ OriginatingTo: isDirect ? msg.userId : msg.conversationId,
599
+ ...mediaPayload,
600
+ });
601
+
602
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
603
+ agentId: route.agentId,
604
+ });
605
+ await rt.channel.session.recordInboundSession({
606
+ storePath,
607
+ sessionKey: route.sessionKey,
608
+ ctx,
609
+ onRecordError: (err: unknown) => {
610
+ log.error("[mixin] session record error", err);
611
+ },
612
+ });
613
+
315
614
  log.info(`[mixin] dispatching ${msg.messageId} from ${msg.userId}`);
316
615
 
317
616
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
318
617
  ctx,
319
618
  cfg,
320
619
  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
- },
620
+ deliver: async (payload) => {
621
+ const replyText = payload.text ?? "";
622
+ if (!replyText) {
623
+ return;
624
+ }
625
+ const recipientId = isDirect ? msg.userId : undefined;
626
+ await deliverMixinReply({
627
+ cfg,
628
+ accountId,
629
+ conversationId: msg.conversationId,
630
+ recipientId,
631
+ text: replyText,
632
+ log,
633
+ });
634
+ },
334
635
  },
335
636
  });
336
637
  }