@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.
- package/README.md +148 -3
- package/README.zh-CN.md +148 -3
- package/package.json +1 -1
- package/src/channel.ts +233 -15
- package/src/config-schema.ts +21 -0
- package/src/config.ts +99 -10
- package/src/inbound-handler.ts +479 -178
- package/src/outbound-plan.ts +197 -0
- package/src/reply-format.ts +90 -23
- package/src/send-service.ts +233 -14
- package/src/status.ts +100 -0
- package/tools/mixin-plugin-onboard/README.md +98 -0
- package/tools/mixin-plugin-onboard/bin/mixin-plugin-onboard.mjs +3 -0
- package/tools/mixin-plugin-onboard/src/commands/doctor.ts +28 -0
- package/tools/mixin-plugin-onboard/src/commands/info.ts +23 -0
- package/tools/mixin-plugin-onboard/src/commands/install.ts +5 -0
- package/tools/mixin-plugin-onboard/src/commands/update.ts +5 -0
- package/tools/mixin-plugin-onboard/src/index.ts +49 -0
- package/tools/mixin-plugin-onboard/src/utils.ts +189 -0
package/src/inbound-handler.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import
|
|
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)
|
|
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)
|
|
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 =
|
|
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 (
|
|
124
|
-
await sendTextMessage(cfg, accountId, conversationId, recipientId, plan.text, log);
|
|
329
|
+
if (dmPolicy === "disabled") {
|
|
125
330
|
return;
|
|
126
331
|
}
|
|
127
332
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
}
|