@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/README.md +690 -421
- package/README.zh-CN.md +693 -431
- package/index.ts +104 -27
- package/openclaw.plugin.json +8 -3
- package/package.json +79 -1
- package/src/blaze-service.ts +24 -7
- package/src/channel.ts +524 -411
- package/src/config-schema.ts +16 -0
- package/src/config.ts +58 -4
- package/src/crypto.ts +5 -0
- package/src/inbound-handler.ts +1205 -637
- package/src/mixpay-service.ts +211 -0
- package/src/mixpay-store.ts +205 -0
- package/src/mixpay-worker.ts +353 -0
- package/src/onboarding.ts +342 -0
- package/src/outbound-plan.ts +26 -7
- package/src/plugin-admin.ts +161 -0
- package/src/reply-format.ts +52 -1
- package/src/runtime.ts +26 -0
- package/src/send-service.ts +24 -27
- package/src/shared.ts +25 -0
- package/src/status.ts +14 -0
- package/src/decrypt.ts +0 -126
- package/tools/mixin-plugin-onboard/README.md +0 -98
- package/tools/mixin-plugin-onboard/bin/mixin-plugin-onboard.mjs +0 -3
- package/tools/mixin-plugin-onboard/src/commands/doctor.ts +0 -28
- package/tools/mixin-plugin-onboard/src/commands/info.ts +0 -23
- package/tools/mixin-plugin-onboard/src/commands/install.ts +0 -5
- package/tools/mixin-plugin-onboard/src/commands/update.ts +0 -5
- package/tools/mixin-plugin-onboard/src/index.ts +0 -49
- package/tools/mixin-plugin-onboard/src/utils.ts +0 -189
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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: (
|
|
197
|
-
|
|
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
|
|
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
|
-
?
|
|
210
|
-
: "
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
|