@invago/mixin 1.0.9 → 1.0.11

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/src/channel.ts CHANGED
@@ -1,417 +1,530 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
- import {
4
- buildChannelConfigSchema,
5
- createDefaultChannelRuntimeState,
6
- formatPairingApproveHint,
7
- resolveChannelMediaMaxBytes,
8
- } from "openclaw/plugin-sdk";
9
- import type { ChannelGatewayContext, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
10
- import { runBlazeLoop } from "./blaze-service.js";
11
- import { MixinConfigSchema } from "./config-schema.js";
12
- import { describeAccount, isConfigured, listAccountIds, resolveAccount, resolveDefaultAccountId, resolveMediaMaxMb } from "./config.js";
13
- import { handleMixinMessage, type MixinInboundMessage } from "./inbound-handler.js";
14
- import { buildMixinOutboundPlanFromReplyPayload, executeMixinOutboundPlan } from "./outbound-plan.js";
15
- import { getMixinRuntime } from "./runtime.js";
16
- import { getOutboxStatus, sendAudioMessage, sendFileMessage, sendTextMessage, startSendWorker } from "./send-service.js";
17
- import { buildMixinAccountSnapshot, buildMixinChannelSummary, resolveMixinStatusSnapshot } from "./status.js";
18
-
19
- type ResolvedMixinAccount = ReturnType<typeof resolveAccount>;
20
-
21
- const BASE_DELAY = 1000;
22
- const MAX_DELAY = 3000;
23
- const MULTIPLIER = 1.5;
24
- const MEDIA_MAX_BYTES = 30 * 1024 * 1024;
25
- const execFileAsync = promisify(execFile);
26
-
27
- async function sleep(ms: number): Promise<void> {
28
- return new Promise((resolve) => setTimeout(resolve, ms));
29
- }
30
-
31
- function maskKey(key: string): string {
32
- if (!key || key.length < 8) {
33
- return "****";
34
- }
35
- return key.slice(0, 4) + "****" + key.slice(-4);
36
- }
37
-
38
- async function resolveAudioDurationSeconds(filePath: string): Promise<number | null> {
39
- try {
40
- const { stdout } = await execFileAsync(
41
- process.platform === "win32" ? "ffprobe.exe" : "ffprobe",
42
- [
43
- "-v",
44
- "error",
45
- "-show_entries",
46
- "format=duration",
47
- "-of",
48
- "default=noprint_wrappers=1:nokey=1",
49
- filePath,
50
- ],
51
- { timeout: 15_000, windowsHide: true },
52
- );
53
- const seconds = Number.parseFloat(stdout.trim());
54
- if (!Number.isFinite(seconds) || seconds <= 0) {
55
- return null;
56
- }
57
- return Math.max(1, Math.ceil(seconds));
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- function resolveMixinMediaMaxBytes(cfg: OpenClawConfig, accountId?: string | null): number {
64
- return resolveChannelMediaMaxBytes({
65
- cfg,
66
- resolveChannelLimitMb: ({ cfg, accountId }) => resolveMediaMaxMb(cfg, accountId),
67
- accountId,
68
- }) ?? MEDIA_MAX_BYTES;
69
- }
70
-
71
- async function deliverOutboundMixinPayload(params: {
72
- cfg: OpenClawConfig;
73
- to: string;
74
- text?: string;
75
- mediaUrls?: string[];
76
- mediaLocalRoots?: readonly string[];
77
- accountId?: string | null;
78
- }): Promise<{ channel: "mixin"; messageId: string }> {
79
- const accountId = params.accountId ?? resolveDefaultAccountId(params.cfg);
80
- const account = resolveAccount(params.cfg, accountId);
81
- const mediaMaxBytes = resolveMixinMediaMaxBytes(params.cfg, accountId);
82
- const runtime = getMixinRuntime();
83
-
84
- const sendMediaUrl = async (mediaUrl: string): Promise<string | undefined> => {
85
- const loaded = await runtime.media.loadWebMedia(mediaUrl, {
86
- maxBytes: mediaMaxBytes,
87
- localRoots: params.mediaLocalRoots,
88
- });
89
- const saved = await runtime.channel.media.saveMediaBuffer(
90
- loaded.buffer,
91
- loaded.contentType,
92
- "mixin",
93
- mediaMaxBytes,
94
- loaded.fileName,
95
- );
96
-
97
- if (loaded.kind === "audio" && account.config.audioSendAsVoiceByDefault !== false) {
98
- const duration = account.config.audioAutoDetectDuration === false
99
- ? null
100
- : await resolveAudioDurationSeconds(saved.path);
101
- if (duration !== null) {
102
- const audioResult = await sendAudioMessage(
103
- params.cfg,
104
- accountId,
105
- params.to,
106
- undefined,
107
- {
108
- filePath: saved.path,
109
- mimeType: saved.contentType ?? loaded.contentType,
110
- duration,
111
- },
112
- );
113
- if (!audioResult.ok) {
114
- throw new Error(audioResult.error ?? "mixin outbound audio send failed");
115
- }
116
- return audioResult.messageId;
117
- }
118
- if (account.config.audioRequireFfprobe) {
119
- throw new Error("ffprobe is required to send mediaUrl audio as Mixin voice");
120
- }
121
- }
122
-
123
- const fileResult = await sendFileMessage(
124
- params.cfg,
125
- accountId,
126
- params.to,
127
- undefined,
128
- {
129
- filePath: saved.path,
130
- fileName: loaded.fileName,
131
- mimeType: saved.contentType ?? loaded.contentType,
132
- },
133
- );
134
- if (!fileResult.ok) {
135
- throw new Error(fileResult.error ?? "mixin outbound file send failed");
136
- }
137
- return fileResult.messageId;
138
- };
139
-
140
- const payloadPlan = buildMixinOutboundPlanFromReplyPayload({
141
- text: params.text,
142
- mediaUrl: params.mediaUrls?.[0],
143
- mediaUrls: params.mediaUrls,
144
- } as ReplyPayload);
145
- for (const warning of payloadPlan.warnings) {
146
- console.warn(`[mixin] outbound plan warning: ${warning}`);
147
- }
148
-
149
- const lastMessageId = await executeMixinOutboundPlan({
150
- cfg: params.cfg,
151
- accountId,
152
- conversationId: params.to,
153
- steps: payloadPlan.steps,
154
- sendMediaUrl,
155
- });
156
-
157
- return { channel: "mixin", messageId: lastMessageId ?? params.to };
158
- }
159
-
160
- export const mixinPlugin = {
161
- id: "mixin",
162
-
163
- meta: {
164
- id: "mixin",
165
- label: "Mixin Messenger",
166
- selectionLabel: "Mixin Messenger (Blaze WebSocket)",
167
- docsPath: "/channels/mixin",
168
- blurb: "Mixin Messenger channel via Blaze WebSocket",
169
- aliases: ["mixin-messenger", "mixin"],
170
- },
171
-
172
- configSchema: buildChannelConfigSchema(MixinConfigSchema),
173
-
174
- capabilities: {
175
- chatTypes: ["direct", "group"] as Array<"direct" | "group">,
176
- reactions: false,
177
- threads: false,
178
- media: true,
179
- nativeCommands: false,
180
- blockStreaming: false,
181
- },
182
-
183
- config: {
184
- listAccountIds,
185
- resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
186
- resolveAccount(cfg, accountId ?? undefined),
187
- defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultAccountId(cfg),
188
- },
189
-
190
- pairing: {
191
- idLabel: "Mixin UUID",
192
- normalizeAllowEntry: (entry: string) => entry.trim().toLowerCase(),
193
- },
194
-
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { uniqueConversationID } from "@mixin.dev/mixin-node-sdk";
4
+ import {
5
+ buildChannelConfigSchema,
6
+ createDefaultChannelRuntimeState,
7
+ formatPairingApproveHint,
8
+ resolveChannelMediaMaxBytes,
9
+ } from "openclaw/plugin-sdk";
10
+ import type { ChannelGatewayContext, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
11
+ import { runBlazeLoop } from "./blaze-service.js";
12
+ import { buildClient, sleep } from "./shared.js";
13
+ import { MixinConfigSchema } from "./config-schema.js";
14
+ import { describeAccount, isConfigured, listAccountIds, resolveAccount, resolveDefaultAccountId, resolveMediaMaxMb } from "./config.js";
15
+ import type { MixinAccountConfig } from "./config-schema.js";
16
+ import { handleMixinMessage, type MixinInboundMessage } from "./inbound-handler.js";
17
+ import { getMixpayStatusSnapshot, startMixpayWorker } from "./mixpay-worker.js";
18
+ import { mixinOnboardingAdapter } from "./onboarding.js";
19
+ import { buildMixinOutboundPlanFromReplyPayload, executeMixinOutboundPlan } from "./outbound-plan.js";
20
+ import { getMixinRuntime, setMixinBlazeSender } from "./runtime.js";
21
+ import { getOutboxStatus, sendAudioMessage, sendFileMessage, sendTextMessage, startSendWorker } from "./send-service.js";
22
+ import { buildMixinAccountSnapshot, buildMixinChannelSummary, resolveMixinStatusSnapshot } from "./status.js";
23
+
24
+ type ResolvedMixinAccount = ReturnType<typeof resolveAccount>;
25
+
26
+ const BASE_DELAY = 1000;
27
+ const MAX_DELAY = 3000;
28
+ const MULTIPLIER = 1.5;
29
+ const MEDIA_MAX_BYTES = 30 * 1024 * 1024;
30
+ const execFileAsync = promisify(execFile);
31
+ const CONVERSATION_CATEGORY_CACHE_TTL_MS = 5 * 60 * 1000;
32
+
33
+ const conversationCategoryCache = new Map<string, {
34
+ category: "CONTACT" | "GROUP";
35
+ expiresAt: number;
36
+ }>();
37
+
38
+ function maskKey(key: string): string {
39
+ if (!key || key.length < 8) {
40
+ return "****";
41
+ }
42
+ return key.slice(0, 4) + "****" + key.slice(-4);
43
+ }
44
+
45
+ async function resolveIsDirectMessage(params: {
46
+ config: MixinAccountConfig;
47
+ conversationId?: string;
48
+ userId?: string;
49
+ log: {
50
+ info: (m: string) => void;
51
+ warn: (m: string) => void;
52
+ };
53
+ }): Promise<boolean> {
54
+ const conversationId = params.conversationId?.trim();
55
+ if (!conversationId) {
56
+ return true;
57
+ }
58
+
59
+ const cached = conversationCategoryCache.get(conversationId);
60
+ if (cached && cached.expiresAt > Date.now()) {
61
+ params.log.info(`[mixin] conversation category resolved from cache: conversationId=${conversationId}, category=${cached.category}`);
62
+ return cached.category !== "GROUP";
63
+ }
64
+
65
+ const now = Date.now();
66
+ for (const [key, entry] of conversationCategoryCache) {
67
+ if (entry.expiresAt <= now) {
68
+ conversationCategoryCache.delete(key);
69
+ }
70
+ }
71
+
72
+ try {
73
+ const client = buildClient(params.config);
74
+ const conversation = await client.conversation.fetch(conversationId);
75
+ const category = conversation.category === "GROUP" ? "GROUP" : "CONTACT";
76
+ conversationCategoryCache.set(conversationId, {
77
+ category,
78
+ expiresAt: Date.now() + CONVERSATION_CATEGORY_CACHE_TTL_MS,
79
+ });
80
+ params.log.info(`[mixin] conversation category resolved: conversationId=${conversationId}, category=${category}`);
81
+ return category !== "GROUP";
82
+ } catch (err) {
83
+ const userId = params.userId?.trim();
84
+ if (userId && params.config.appId) {
85
+ const directConversationId = uniqueConversationID(params.config.appId, userId);
86
+ if (directConversationId === conversationId) {
87
+ params.log.info(
88
+ `[mixin] conversation category inferred locally: conversationId=${conversationId}, category=CONTACT`,
89
+ );
90
+ conversationCategoryCache.set(conversationId, {
91
+ category: "CONTACT",
92
+ expiresAt: Date.now() + CONVERSATION_CATEGORY_CACHE_TTL_MS,
93
+ });
94
+ return true;
95
+ }
96
+ }
97
+ params.log.warn(
98
+ `[mixin] failed to resolve conversation category: conversationId=${conversationId}, error=${err instanceof Error ? err.message : String(err)}`,
99
+ );
100
+ return false;
101
+ }
102
+ }
103
+
104
+ async function resolveAudioDurationSeconds(filePath: string): Promise<number | null> {
105
+ try {
106
+ const { stdout } = await execFileAsync(
107
+ process.platform === "win32" ? "ffprobe.exe" : "ffprobe",
108
+ [
109
+ "-v",
110
+ "error",
111
+ "-show_entries",
112
+ "format=duration",
113
+ "-of",
114
+ "default=noprint_wrappers=1:nokey=1",
115
+ filePath,
116
+ ],
117
+ { timeout: 15_000, windowsHide: true },
118
+ );
119
+ const seconds = Number.parseFloat(stdout.trim());
120
+ if (!Number.isFinite(seconds) || seconds <= 0) {
121
+ return null;
122
+ }
123
+ return Math.max(1, Math.ceil(seconds));
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function resolveMixinMediaMaxBytes(cfg: OpenClawConfig, accountId?: string | null): number {
130
+ return resolveChannelMediaMaxBytes({
131
+ cfg,
132
+ resolveChannelLimitMb: ({ cfg, accountId }) => resolveMediaMaxMb(cfg, accountId),
133
+ accountId,
134
+ }) ?? MEDIA_MAX_BYTES;
135
+ }
136
+
137
+ async function deliverOutboundMixinPayload(params: {
138
+ cfg: OpenClawConfig;
139
+ to: string;
140
+ text?: string;
141
+ mediaUrls?: string[];
142
+ mediaLocalRoots?: readonly string[];
143
+ accountId?: string | null;
144
+ }): Promise<{ channel: "mixin"; messageId: string }> {
145
+ const accountId = params.accountId ?? resolveDefaultAccountId(params.cfg);
146
+ const account = resolveAccount(params.cfg, accountId);
147
+ const mediaMaxBytes = resolveMixinMediaMaxBytes(params.cfg, accountId);
148
+ const runtime = getMixinRuntime();
149
+
150
+ const sendMediaUrl = async (mediaUrl: string): Promise<string | undefined> => {
151
+ const loaded = await runtime.media.loadWebMedia(mediaUrl, {
152
+ maxBytes: mediaMaxBytes,
153
+ localRoots: params.mediaLocalRoots,
154
+ });
155
+ const saved = await runtime.channel.media.saveMediaBuffer(
156
+ loaded.buffer,
157
+ loaded.contentType,
158
+ "mixin",
159
+ mediaMaxBytes,
160
+ loaded.fileName,
161
+ );
162
+
163
+ if (loaded.kind === "audio" && account.config.audioSendAsVoiceByDefault !== false) {
164
+ const duration = account.config.audioAutoDetectDuration === false
165
+ ? null
166
+ : await resolveAudioDurationSeconds(saved.path);
167
+ if (duration !== null) {
168
+ const audioResult = await sendAudioMessage(
169
+ params.cfg,
170
+ accountId,
171
+ params.to,
172
+ undefined,
173
+ {
174
+ filePath: saved.path,
175
+ mimeType: saved.contentType ?? loaded.contentType,
176
+ duration,
177
+ },
178
+ );
179
+ if (!audioResult.ok) {
180
+ throw new Error(audioResult.error ?? "mixin outbound audio send failed");
181
+ }
182
+ return audioResult.messageId;
183
+ }
184
+ if (account.config.audioRequireFfprobe) {
185
+ throw new Error("ffprobe is required to send mediaUrl audio as Mixin voice");
186
+ }
187
+ }
188
+
189
+ const fileResult = await sendFileMessage(
190
+ params.cfg,
191
+ accountId,
192
+ params.to,
193
+ undefined,
194
+ {
195
+ filePath: saved.path,
196
+ fileName: loaded.fileName,
197
+ mimeType: saved.contentType ?? loaded.contentType,
198
+ },
199
+ );
200
+ if (!fileResult.ok) {
201
+ throw new Error(fileResult.error ?? "mixin outbound file send failed");
202
+ }
203
+ return fileResult.messageId;
204
+ };
205
+
206
+ const payloadPlan = buildMixinOutboundPlanFromReplyPayload({
207
+ text: params.text,
208
+ mediaUrl: params.mediaUrls?.[0],
209
+ mediaUrls: params.mediaUrls,
210
+ } as ReplyPayload);
211
+ for (const warning of payloadPlan.warnings) {
212
+ console.warn(`[mixin] outbound plan warning: ${warning}`);
213
+ }
214
+
215
+ const lastMessageId = await executeMixinOutboundPlan({
216
+ cfg: params.cfg,
217
+ accountId,
218
+ conversationId: params.to,
219
+ steps: payloadPlan.steps,
220
+ sendMediaUrl,
221
+ });
222
+
223
+ return { channel: "mixin", messageId: lastMessageId ?? params.to };
224
+ }
225
+
226
+ export const mixinPlugin = {
227
+ id: "mixin",
228
+
229
+ meta: {
230
+ id: "mixin",
231
+ label: "Mixin Messenger",
232
+ selectionLabel: "Mixin Messenger (Blaze WebSocket)",
233
+ docsPath: "/channels/mixin",
234
+ blurb: "Mixin Messenger channel via Blaze WebSocket",
235
+ aliases: ["mixin-messenger", "mixin"],
236
+ },
237
+
238
+ configSchema: {
239
+ ...buildChannelConfigSchema(MixinConfigSchema),
240
+ uiHints: {
241
+ appId: { label: "Mixin App ID" },
242
+ sessionId: { label: "Session ID", sensitive: true },
243
+ serverPublicKey: { label: "Server Public Key", sensitive: true },
244
+ sessionPrivateKey: { label: "Session Private Key", sensitive: true },
245
+ "proxy.url": { label: "Proxy URL", advanced: true },
246
+ "proxy.username": { label: "Proxy Username", advanced: true },
247
+ "proxy.password": { label: "Proxy Password", sensitive: true, advanced: true },
248
+ "mixpay.payeeId": { label: "MixPay Payee ID", advanced: true },
249
+ },
250
+ },
251
+
252
+ reload: {
253
+ configPrefixes: ["channels.mixin"],
254
+ },
255
+
256
+ capabilities: {
257
+ chatTypes: ["direct", "group"] as Array<"direct" | "group">,
258
+ reactions: false,
259
+ threads: false,
260
+ media: true,
261
+ nativeCommands: false,
262
+ blockStreaming: false,
263
+ },
264
+
265
+ onboarding: mixinOnboardingAdapter,
266
+ config: {
267
+ listAccountIds,
268
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
269
+ resolveAccount(cfg, accountId ?? undefined),
270
+ defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultAccountId(cfg),
271
+ inspectAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
272
+ const resolvedAccount = resolveAccount(cfg, accountId ?? undefined);
273
+ const statusSnapshot = resolveMixinStatusSnapshot(cfg, resolvedAccount.accountId);
274
+ return buildMixinAccountSnapshot({
275
+ account: resolvedAccount,
276
+ runtime: null,
277
+ probe: null,
278
+ defaultAccountId: statusSnapshot.defaultAccountId,
279
+ outboxPending: statusSnapshot.outboxPending,
280
+ });
281
+ },
282
+ },
283
+
284
+ pairing: {
285
+ idLabel: "Mixin UUID",
286
+ normalizeAllowEntry: (entry: string) => entry.trim().toLowerCase(),
287
+ },
288
+
195
289
  security: {
196
- resolveDmPolicy: ({ account, accountId }: { account: ResolvedMixinAccount; accountId?: string | null }) => {
197
- const allowFrom = account.config.allowFrom ?? [];
290
+ resolveDmPolicy: (
291
+ { account, accountId }: { account?: ResolvedMixinAccount; accountId?: string | null },
292
+ ) => {
293
+ const allowFrom = account?.config?.allowFrom ?? [];
198
294
  const basePath = accountId && accountId !== "default" ? `.accounts.${accountId}` : "";
199
- const policy = account.config.dmPolicy ?? "pairing";
295
+ const policy = account?.config?.dmPolicy ?? "pairing";
200
296
 
201
297
  return {
202
298
  policy,
203
299
  allowFrom,
204
- policyPath: `channels.mixin${basePath}.dmPolicy`,
205
- allowFromPath: `channels.mixin${basePath}.allowFrom`,
206
- approveHint: policy === "pairing"
207
- ? formatPairingApproveHint("mixin")
208
- : allowFrom.length > 0
209
- ? `已配置白名单用户数 ${allowFrom.length},将用户的 Mixin UUID 添加到 allowFrom 列表即可授权`
210
- : "将用户的 Mixin UUID 添加到 allowFrom 列表即可授权",
211
- };
212
- },
213
- },
214
-
215
- outbound: {
216
- deliveryMode: "direct" as const,
217
- textChunkLimit: 4000,
218
- sendPayload: async (ctx: {
219
- cfg: OpenClawConfig;
220
- to: string;
221
- payload: ReplyPayload;
222
- mediaLocalRoots?: readonly string[];
223
- accountId?: string | null;
224
- }) =>
225
- deliverOutboundMixinPayload({
226
- cfg: ctx.cfg,
227
- to: ctx.to,
228
- text: ctx.payload.text,
229
- mediaUrls: ctx.payload.mediaUrls && ctx.payload.mediaUrls.length > 0
230
- ? ctx.payload.mediaUrls
231
- : ctx.payload.mediaUrl
232
- ? [ctx.payload.mediaUrl]
233
- : [],
234
- mediaLocalRoots: ctx.mediaLocalRoots,
235
- accountId: ctx.accountId,
236
- }),
237
-
238
- sendText: async (ctx: {
239
- cfg: OpenClawConfig;
240
- to: string;
241
- text: string;
242
- accountId?: string | null;
243
- }) => {
244
- const id = ctx.accountId ?? resolveDefaultAccountId(ctx.cfg);
245
- const result = await sendTextMessage(ctx.cfg, id, ctx.to, undefined, ctx.text);
246
- if (result.ok) {
247
- return { channel: "mixin", messageId: result.messageId ?? ctx.to };
248
- }
249
- throw new Error(result.error ?? "sendText failed");
250
- },
251
- sendMedia: async (ctx: {
252
- cfg: OpenClawConfig;
253
- to: string;
254
- text: string;
255
- mediaUrl?: string;
256
- mediaLocalRoots?: readonly string[];
257
- accountId?: string | null;
258
- }) =>
259
- deliverOutboundMixinPayload({
260
- cfg: ctx.cfg,
261
- to: ctx.to,
262
- text: ctx.text,
263
- mediaUrls: ctx.mediaUrl ? [ctx.mediaUrl] : [],
264
- mediaLocalRoots: ctx.mediaLocalRoots,
265
- accountId: ctx.accountId,
266
- }),
267
- },
268
-
269
- gateway: {
270
- startAccount: async (ctx: ChannelGatewayContext<ResolvedMixinAccount>): Promise<unknown> => {
271
- const { account, cfg, abortSignal } = ctx;
272
- const log = (ctx as any).log ?? {
273
- info: (m: string) => console.log(`[mixin] ${m}`),
274
- warn: (m: string) => console.warn(`[mixin] ${m}`),
275
- error: (m: string, e?: unknown) => console.error(`[mixin] ${m}`, e),
276
- };
277
- const accountId = account.accountId;
278
- const config = account.config;
279
-
280
- await startSendWorker(cfg, log);
281
- const outboxStatus = await getOutboxStatus().catch(() => null);
282
- const statusSnapshot = resolveMixinStatusSnapshot(cfg, accountId, outboxStatus);
283
- ctx.setStatus({
284
- accountId,
285
- ...statusSnapshot,
286
- });
287
-
288
- let stopped = false;
289
- const stop = () => {
290
- stopped = true;
291
- };
292
- abortSignal?.addEventListener("abort", stop);
293
-
294
- let attempt = 1;
295
- let delay = BASE_DELAY;
296
-
297
- const runLoop = async () => {
298
- while (!stopped) {
299
- try {
300
- log.info(`connecting to Mixin Blaze (attempt ${attempt})`);
301
- log.info(`config: appId=${maskKey(config.appId!)}, sessionId=${maskKey(config.sessionId!)}`);
302
-
303
- await runBlazeLoop({
304
- config,
305
- options: { parse: false, syncAck: true },
306
- log,
307
- abortSignal,
308
- handler: {
309
- onMessage: async (rawMsg: any) => {
310
- if (stopped) {
311
- return;
312
- }
313
- if (!rawMsg || !rawMsg.message_id) {
314
- return;
315
- }
316
- if (!rawMsg.user_id || rawMsg.user_id === config.appId) {
317
- return;
318
- }
319
-
320
- const isDirect = rawMsg.conversation_id === undefined
321
- ? true
322
- : !rawMsg.representative_id;
323
-
324
- const msg: MixinInboundMessage = {
325
- conversationId: rawMsg.conversation_id ?? "",
326
- userId: rawMsg.user_id,
327
- messageId: rawMsg.message_id,
328
- category: rawMsg.category ?? "PLAIN_TEXT",
329
- data: rawMsg.data_base64 ?? rawMsg.data ?? "",
330
- createdAt: rawMsg.created_at ?? new Date().toISOString(),
331
- };
332
-
333
- try {
334
- await handleMixinMessage({ cfg, accountId, msg, isDirect, log });
335
- } catch (err) {
336
- log.error(`error handling message ${msg.messageId}`, err);
337
- }
338
- },
339
- },
340
- });
341
-
342
- if (stopped) {
343
- break;
344
- }
345
-
346
- attempt = 1;
347
- delay = BASE_DELAY;
348
- } catch (err) {
349
- if (stopped) {
350
- break;
351
- }
352
- const errorMsg = err instanceof Error ? err.message : String(err);
353
- log.error(`connection error: ${errorMsg}`, err);
354
- log.warn(`retrying in ${delay}ms (attempt ${attempt})`);
355
- await sleep(delay);
356
- delay = Math.min(delay * MULTIPLIER, MAX_DELAY);
357
- attempt++;
358
- }
359
- }
360
-
361
- log.info("gateway stopped");
362
- };
363
-
364
- try {
365
- await runLoop();
366
- } catch (err) {
367
- const msg = err instanceof Error ? err.message : String(err);
368
- log.error(`[internal] unexpected loop error: ${msg}`, err);
369
- }
370
-
371
- return { stop };
372
- },
373
- },
374
-
375
- status: {
376
- defaultRuntime: createDefaultChannelRuntimeState("default"),
377
- buildChannelSummary: (params: {
378
- snapshot: {
379
- configured?: boolean | null;
380
- running?: boolean | null;
381
- lastStartAt?: number | null;
382
- lastStopAt?: number | null;
383
- lastError?: string | null;
384
- defaultAccountId?: string | null;
385
- outboxDir?: string | null;
386
- outboxFile?: string | null;
387
- outboxPending?: number | null;
388
- mediaMaxMb?: number | null;
389
- };
390
- }) => buildMixinChannelSummary({ snapshot: params.snapshot }),
391
- buildAccountSnapshot: (params: {
392
- account: ResolvedMixinAccount;
393
- runtime?: {
394
- running?: boolean | null;
395
- lastStartAt?: number | null;
396
- lastStopAt?: number | null;
397
- lastError?: string | null;
398
- lastInboundAt?: number | null;
399
- lastOutboundAt?: number | null;
400
- } | null;
401
- probe?: unknown;
402
- cfg: OpenClawConfig;
403
- }) => {
404
- const { account, runtime, probe, cfg } = params;
405
- const statusSnapshot = resolveMixinStatusSnapshot(cfg, account.accountId);
406
- return buildMixinAccountSnapshot({
407
- account,
408
- runtime,
409
- probe,
410
- defaultAccountId: statusSnapshot.defaultAccountId,
411
- outboxPending: statusSnapshot.outboxPending,
412
- });
413
- },
414
- },
415
- };
416
-
417
- export { describeAccount, isConfigured };
300
+ policyPath: `channels.mixin${basePath}.dmPolicy`,
301
+ allowFromPath: `channels.mixin${basePath}.allowFrom`,
302
+ approveHint: policy === "pairing"
303
+ ? formatPairingApproveHint("mixin")
304
+ : allowFrom.length > 0
305
+ ? `宸查厤缃櫧鍚嶅崟鐢ㄦ埛鏁?${allowFrom.length}锛屽皢鐢ㄦ埛鐨?Mixin UUID 娣诲姞鍒?allowFrom 鍒楄〃鍗冲彲鎺堟潈`
306
+ : "灏嗙敤鎴风殑 Mixin UUID 娣诲姞鍒?allowFrom 鍒楄〃鍗冲彲鎺堟潈",
307
+ };
308
+ },
309
+ },
310
+
311
+ outbound: {
312
+ deliveryMode: "direct" as const,
313
+ textChunkLimit: 4000,
314
+ sendPayload: async (ctx: {
315
+ cfg: OpenClawConfig;
316
+ to: string;
317
+ payload: ReplyPayload;
318
+ mediaLocalRoots?: readonly string[];
319
+ accountId?: string | null;
320
+ }) =>
321
+ deliverOutboundMixinPayload({
322
+ cfg: ctx.cfg,
323
+ to: ctx.to,
324
+ text: ctx.payload.text,
325
+ mediaUrls: ctx.payload.mediaUrls && ctx.payload.mediaUrls.length > 0
326
+ ? ctx.payload.mediaUrls
327
+ : ctx.payload.mediaUrl
328
+ ? [ctx.payload.mediaUrl]
329
+ : [],
330
+ mediaLocalRoots: ctx.mediaLocalRoots,
331
+ accountId: ctx.accountId,
332
+ }),
333
+
334
+ sendText: async (ctx: {
335
+ cfg: OpenClawConfig;
336
+ to: string;
337
+ text: string;
338
+ accountId?: string | null;
339
+ }) => {
340
+ const id = ctx.accountId ?? resolveDefaultAccountId(ctx.cfg);
341
+ const result = await sendTextMessage(ctx.cfg, id, ctx.to, undefined, ctx.text);
342
+ if (result.ok) {
343
+ return { channel: "mixin", messageId: result.messageId ?? ctx.to };
344
+ }
345
+ throw new Error(result.error ?? "sendText failed");
346
+ },
347
+ sendMedia: async (ctx: {
348
+ cfg: OpenClawConfig;
349
+ to: string;
350
+ text: string;
351
+ mediaUrl?: string;
352
+ mediaLocalRoots?: readonly string[];
353
+ accountId?: string | null;
354
+ }) =>
355
+ deliverOutboundMixinPayload({
356
+ cfg: ctx.cfg,
357
+ to: ctx.to,
358
+ text: ctx.text,
359
+ mediaUrls: ctx.mediaUrl ? [ctx.mediaUrl] : [],
360
+ mediaLocalRoots: ctx.mediaLocalRoots,
361
+ accountId: ctx.accountId,
362
+ }),
363
+ },
364
+
365
+ gateway: {
366
+ startAccount: async (ctx: ChannelGatewayContext<ResolvedMixinAccount>): Promise<unknown> => {
367
+ const { account, cfg, abortSignal } = ctx;
368
+ const log = (ctx as any).log ?? {
369
+ info: (m: string) => console.log(`[mixin] ${m}`),
370
+ warn: (m: string) => console.warn(`[mixin] ${m}`),
371
+ error: (m: string, e?: unknown) => console.error(`[mixin] ${m}`, e),
372
+ };
373
+ const accountId = account.accountId;
374
+ const config = account.config;
375
+
376
+ await startSendWorker(cfg, log);
377
+ const outboxStatus = await getOutboxStatus().catch(() => null);
378
+ await startMixpayWorker(cfg, log);
379
+ const mixpayStatus = await getMixpayStatusSnapshot().catch(() => null);
380
+ const statusSnapshot = resolveMixinStatusSnapshot(cfg, accountId, outboxStatus, mixpayStatus);
381
+ ctx.setStatus({
382
+ accountId,
383
+ ...statusSnapshot,
384
+ });
385
+
386
+ let stopped = false;
387
+ const stop = () => {
388
+ stopped = true;
389
+ setMixinBlazeSender(accountId, null);
390
+ };
391
+ abortSignal?.addEventListener("abort", stop);
392
+
393
+ let attempt = 1;
394
+ let delay = BASE_DELAY;
395
+
396
+ const runLoop = async () => {
397
+ while (!stopped) {
398
+ try {
399
+ log.info(`connecting to Mixin Blaze (attempt ${attempt})`);
400
+ log.info(`config: appId=${maskKey(config.appId!)}, sessionId=${maskKey(config.sessionId!)}`);
401
+
402
+ await runBlazeLoop({
403
+ config,
404
+ options: { parse: false, syncAck: true },
405
+ log,
406
+ abortSignal,
407
+ onSenderReady: (sender) => {
408
+ setMixinBlazeSender(accountId, sender);
409
+ },
410
+ handler: {
411
+ onMessage: async (rawMsg: any) => {
412
+ if (stopped) {
413
+ return;
414
+ }
415
+ if (!rawMsg || !rawMsg.message_id) {
416
+ return;
417
+ }
418
+ if (!rawMsg.user_id || rawMsg.user_id === config.appId) {
419
+ return;
420
+ }
421
+
422
+ const isDirect = await resolveIsDirectMessage({
423
+ config,
424
+ conversationId: rawMsg.conversation_id,
425
+ userId: rawMsg.user_id,
426
+ log,
427
+ });
428
+ log.info(
429
+ `[mixin] inbound route context: messageId=${rawMsg.message_id}, conversationId=${rawMsg.conversation_id ?? ""}, userId=${rawMsg.user_id}, isDirect=${isDirect}`,
430
+ );
431
+
432
+ const msg: MixinInboundMessage = {
433
+ conversationId: rawMsg.conversation_id ?? "",
434
+ userId: rawMsg.user_id,
435
+ messageId: rawMsg.message_id,
436
+ category: rawMsg.category ?? "PLAIN_TEXT",
437
+ data: rawMsg.data_base64 ?? rawMsg.data ?? "",
438
+ createdAt: rawMsg.created_at ?? new Date().toISOString(),
439
+ };
440
+
441
+ try {
442
+ await handleMixinMessage({ cfg, accountId, msg, isDirect, log });
443
+ } catch (err) {
444
+ log.error(`error handling message ${msg.messageId}`, err);
445
+ }
446
+ },
447
+ },
448
+ });
449
+
450
+ if (stopped) {
451
+ break;
452
+ }
453
+
454
+ attempt = 1;
455
+ delay = BASE_DELAY;
456
+ } catch (err) {
457
+ if (stopped) {
458
+ break;
459
+ }
460
+ const errorMsg = err instanceof Error ? err.message : String(err);
461
+ log.error(`connection error: ${errorMsg}`, err);
462
+ log.warn(`retrying in ${delay}ms (attempt ${attempt})`);
463
+ await sleep(delay);
464
+ delay = Math.min(delay * MULTIPLIER, MAX_DELAY);
465
+ attempt++;
466
+ }
467
+ }
468
+
469
+ log.info("gateway stopped");
470
+ };
471
+
472
+ try {
473
+ await runLoop();
474
+ } catch (err) {
475
+ const msg = err instanceof Error ? err.message : String(err);
476
+ log.error(`[internal] unexpected loop error: ${msg}`, err);
477
+ }
478
+
479
+ return { stop };
480
+ },
481
+ },
482
+
483
+ status: {
484
+ defaultRuntime: createDefaultChannelRuntimeState("default"),
485
+ buildChannelSummary: (params: {
486
+ snapshot: {
487
+ configured?: boolean | null;
488
+ running?: boolean | null;
489
+ lastStartAt?: number | null;
490
+ lastStopAt?: number | null;
491
+ lastError?: string | null;
492
+ defaultAccountId?: string | null;
493
+ outboxDir?: string | null;
494
+ outboxFile?: string | null;
495
+ outboxPending?: number | null;
496
+ mediaMaxMb?: number | null;
497
+ };
498
+ }) => buildMixinChannelSummary({ snapshot: params.snapshot }),
499
+ buildAccountSnapshot: (params: {
500
+ account: ResolvedMixinAccount;
501
+ runtime?: {
502
+ running?: boolean | null;
503
+ lastStartAt?: number | null;
504
+ lastStopAt?: number | null;
505
+ lastError?: string | null;
506
+ lastInboundAt?: number | null;
507
+ lastOutboundAt?: number | null;
508
+ } | null;
509
+ probe?: unknown;
510
+ cfg: OpenClawConfig;
511
+ }) => {
512
+ const { account, runtime, probe, cfg } = params;
513
+ const statusSnapshot = resolveMixinStatusSnapshot(cfg, account.accountId);
514
+ return buildMixinAccountSnapshot({
515
+ account,
516
+ runtime,
517
+ probe,
518
+ defaultAccountId: statusSnapshot.defaultAccountId,
519
+ outboxPending: statusSnapshot.outboxPending,
520
+ });
521
+ },
522
+ },
523
+ };
524
+
525
+ export { describeAccount, isConfigured };
526
+
527
+
528
+
529
+
530
+