@invago/mixin 1.0.8 → 1.0.10
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 +328 -4
- package/README.zh-CN.md +386 -69
- package/package.json +79 -1
- package/src/blaze-service.ts +24 -7
- package/src/channel.ts +185 -42
- package/src/config-schema.ts +36 -1
- package/src/config.ts +103 -10
- package/src/crypto.ts +5 -0
- package/src/inbound-handler.ts +1205 -576
- package/src/mixpay-service.ts +211 -0
- package/src/mixpay-store.ts +205 -0
- package/src/mixpay-worker.ts +353 -0
- package/src/outbound-plan.ts +216 -0
- package/src/reply-format.ts +89 -24
- package/src/runtime.ts +26 -0
- package/src/send-service.ts +35 -27
- package/src/shared.ts +25 -0
- package/src/status.ts +114 -0
- package/src/decrypt.ts +0 -126
package/src/reply-format.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { MixinCollectRequest } from "./mixpay-worker.js";
|
|
1
2
|
import type { MixinAudio, MixinButton, MixinCard, MixinFile } from "./send-service.js";
|
|
2
3
|
|
|
3
4
|
type LinkItem = {
|
|
@@ -10,14 +11,19 @@ export type MixinReplyPlan =
|
|
|
10
11
|
| { kind: "post"; text: string }
|
|
11
12
|
| { kind: "file"; file: MixinFile }
|
|
12
13
|
| { kind: "audio"; audio: MixinAudio }
|
|
14
|
+
| { kind: "collect"; collect: MixinCollectRequest }
|
|
13
15
|
| { kind: "buttons"; intro?: string; buttons: MixinButton[] }
|
|
14
16
|
| { kind: "card"; card: MixinCard };
|
|
15
17
|
|
|
18
|
+
export type MixinReplyPlanResolution =
|
|
19
|
+
| { matchedTemplate: false; plan: MixinReplyPlan | null }
|
|
20
|
+
| { matchedTemplate: true; plan: MixinReplyPlan | null; error?: string };
|
|
21
|
+
|
|
16
22
|
const MAX_BUTTONS = 6;
|
|
17
23
|
const MAX_BUTTON_LABEL = 36;
|
|
18
24
|
const MAX_CARD_TITLE = 36;
|
|
19
25
|
const MAX_CARD_DESCRIPTION = 120;
|
|
20
|
-
const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card|file|audio)\s*\n([\s\S]*?)\n```$/i;
|
|
26
|
+
const TEMPLATE_REGEX = /^```mixin-(text|post|buttons|card|file|audio|collect)\s*\n([\s\S]*?)\n```$/i;
|
|
21
27
|
|
|
22
28
|
function truncate(value: string, limit: number): string {
|
|
23
29
|
return value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 3))}...`;
|
|
@@ -166,40 +172,89 @@ function parseAudioTemplate(body: string): MixinReplyPlan | null {
|
|
|
166
172
|
};
|
|
167
173
|
}
|
|
168
174
|
|
|
169
|
-
function
|
|
175
|
+
function parseCollectTemplate(body: string): MixinReplyPlan | null {
|
|
176
|
+
const parsed = parseJsonTemplate<{
|
|
177
|
+
amount?: unknown;
|
|
178
|
+
assetId?: unknown;
|
|
179
|
+
quoteAssetId?: unknown;
|
|
180
|
+
settlementAssetId?: unknown;
|
|
181
|
+
memo?: unknown;
|
|
182
|
+
orderId?: unknown;
|
|
183
|
+
expireMinutes?: unknown;
|
|
184
|
+
}>(body);
|
|
185
|
+
if (!parsed) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const amount = typeof parsed.amount === "string"
|
|
190
|
+
? normalizeWhitespace(parsed.amount)
|
|
191
|
+
: typeof parsed.amount === "number"
|
|
192
|
+
? String(parsed.amount)
|
|
193
|
+
: "";
|
|
194
|
+
const assetId = typeof parsed.assetId === "string"
|
|
195
|
+
? normalizeWhitespace(parsed.assetId)
|
|
196
|
+
: typeof parsed.quoteAssetId === "string"
|
|
197
|
+
? normalizeWhitespace(parsed.quoteAssetId)
|
|
198
|
+
: "";
|
|
199
|
+
if (!amount) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
kind: "collect",
|
|
205
|
+
collect: {
|
|
206
|
+
amount,
|
|
207
|
+
assetId: assetId || undefined,
|
|
208
|
+
settlementAssetId: typeof parsed.settlementAssetId === "string"
|
|
209
|
+
? normalizeWhitespace(parsed.settlementAssetId)
|
|
210
|
+
: undefined,
|
|
211
|
+
memo: typeof parsed.memo === "string" ? normalizeWhitespace(parsed.memo) : undefined,
|
|
212
|
+
orderId: typeof parsed.orderId === "string" ? normalizeWhitespace(parsed.orderId) : undefined,
|
|
213
|
+
expireMinutes: typeof parsed.expireMinutes === "number" && Number.isFinite(parsed.expireMinutes)
|
|
214
|
+
? parsed.expireMinutes
|
|
215
|
+
: undefined,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseExplicitTemplate(text: string): MixinReplyPlanResolution {
|
|
170
221
|
const match = text.match(TEMPLATE_REGEX);
|
|
171
222
|
if (!match) {
|
|
172
|
-
return null;
|
|
223
|
+
return { matchedTemplate: false, plan: null };
|
|
173
224
|
}
|
|
174
225
|
|
|
175
226
|
const templateType = (match[1] ?? "").toLowerCase();
|
|
176
227
|
const body = match[2] ?? "";
|
|
177
228
|
|
|
178
229
|
if (templateType === "text") {
|
|
179
|
-
return parseTextTemplate(body);
|
|
230
|
+
return { matchedTemplate: true, plan: parseTextTemplate(body), error: "Invalid mixin-text template body" };
|
|
180
231
|
}
|
|
181
232
|
|
|
182
233
|
if (templateType === "post") {
|
|
183
|
-
return parsePostTemplate(body);
|
|
234
|
+
return { matchedTemplate: true, plan: parsePostTemplate(body), error: "Invalid mixin-post template body" };
|
|
184
235
|
}
|
|
185
236
|
|
|
186
237
|
if (templateType === "buttons") {
|
|
187
|
-
return parseButtonsTemplate(body);
|
|
238
|
+
return { matchedTemplate: true, plan: parseButtonsTemplate(body), error: "Invalid mixin-buttons template JSON" };
|
|
188
239
|
}
|
|
189
240
|
|
|
190
241
|
if (templateType === "card") {
|
|
191
|
-
return parseCardTemplate(body);
|
|
242
|
+
return { matchedTemplate: true, plan: parseCardTemplate(body), error: "Invalid mixin-card template JSON" };
|
|
192
243
|
}
|
|
193
244
|
|
|
194
245
|
if (templateType === "file") {
|
|
195
|
-
return parseFileTemplate(body);
|
|
246
|
+
return { matchedTemplate: true, plan: parseFileTemplate(body), error: "Invalid mixin-file template JSON" };
|
|
196
247
|
}
|
|
197
248
|
|
|
198
249
|
if (templateType === "audio") {
|
|
199
|
-
return parseAudioTemplate(body);
|
|
250
|
+
return { matchedTemplate: true, plan: parseAudioTemplate(body), error: "Invalid mixin-audio template JSON" };
|
|
200
251
|
}
|
|
201
252
|
|
|
202
|
-
|
|
253
|
+
if (templateType === "collect") {
|
|
254
|
+
return { matchedTemplate: true, plan: parseCollectTemplate(body), error: "Invalid mixin-collect template JSON" };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { matchedTemplate: true, plan: null, error: "Unknown Mixin template type" };
|
|
203
258
|
}
|
|
204
259
|
|
|
205
260
|
function toPlainText(text: string): string {
|
|
@@ -288,21 +343,21 @@ function isLongStructuredText(text: string): boolean {
|
|
|
288
343
|
);
|
|
289
344
|
}
|
|
290
345
|
|
|
291
|
-
export function
|
|
346
|
+
export function resolveMixinReplyPlan(text: string): MixinReplyPlanResolution {
|
|
292
347
|
const normalized = normalizeWhitespace(text);
|
|
293
348
|
if (!normalized) {
|
|
294
|
-
return null;
|
|
349
|
+
return { matchedTemplate: false, plan: null };
|
|
295
350
|
}
|
|
296
351
|
|
|
297
352
|
const explicit = parseExplicitTemplate(normalized);
|
|
298
|
-
if (explicit) {
|
|
353
|
+
if (explicit.matchedTemplate) {
|
|
299
354
|
return explicit;
|
|
300
355
|
}
|
|
301
356
|
|
|
302
357
|
const links = extractLinks(normalized);
|
|
303
358
|
|
|
304
359
|
if (isLongStructuredText(normalized)) {
|
|
305
|
-
return { kind: "post", text: normalized };
|
|
360
|
+
return { matchedTemplate: false, plan: { kind: "post", text: normalized } };
|
|
306
361
|
}
|
|
307
362
|
|
|
308
363
|
if (links.length >= 2 && links.length <= MAX_BUTTONS) {
|
|
@@ -310,9 +365,12 @@ export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
|
|
|
310
365
|
normalized.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "").replace(/https?:\/\/[^\s)]+/g, ""),
|
|
311
366
|
);
|
|
312
367
|
return {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
368
|
+
matchedTemplate: false,
|
|
369
|
+
plan: {
|
|
370
|
+
kind: "buttons",
|
|
371
|
+
intro: intro || undefined,
|
|
372
|
+
buttons: buildButtons(links),
|
|
373
|
+
},
|
|
316
374
|
};
|
|
317
375
|
}
|
|
318
376
|
|
|
@@ -320,15 +378,22 @@ export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
|
|
|
320
378
|
const title = detectTitle(normalized, links[0].label);
|
|
321
379
|
const description = detectCardDescription(normalized, title) || truncate(links[0].url, MAX_CARD_DESCRIPTION);
|
|
322
380
|
return {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
381
|
+
matchedTemplate: false,
|
|
382
|
+
plan: {
|
|
383
|
+
kind: "card",
|
|
384
|
+
card: {
|
|
385
|
+
title,
|
|
386
|
+
description,
|
|
387
|
+
action: links[0].url,
|
|
388
|
+
shareable: true,
|
|
389
|
+
},
|
|
329
390
|
},
|
|
330
391
|
};
|
|
331
392
|
}
|
|
332
393
|
|
|
333
|
-
return { kind: "text", text: toPlainText(normalized) };
|
|
394
|
+
return { matchedTemplate: false, plan: { kind: "text", text: toPlainText(normalized) } };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
|
|
398
|
+
return resolveMixinReplyPlan(text).plan;
|
|
334
399
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { MixinSupportedMessageCategory } from "./send-service.js";
|
|
2
3
|
|
|
3
4
|
let runtime: PluginRuntime | null = null;
|
|
5
|
+
const blazeSenders = new Map<string, MixinBlazeSender>();
|
|
6
|
+
|
|
7
|
+
export type MixinBlazeOutboundMessage = {
|
|
8
|
+
conversationId: string;
|
|
9
|
+
messageId: string;
|
|
10
|
+
category: MixinSupportedMessageCategory;
|
|
11
|
+
dataBase64: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type MixinBlazeSender = (message: MixinBlazeOutboundMessage) => Promise<void>;
|
|
4
15
|
|
|
5
16
|
export function setMixinRuntime(next: PluginRuntime): void {
|
|
6
17
|
runtime = next;
|
|
@@ -10,3 +21,18 @@ export function getMixinRuntime(): PluginRuntime {
|
|
|
10
21
|
if (!runtime) throw new Error("Mixin runtime not initialized");
|
|
11
22
|
return runtime;
|
|
12
23
|
}
|
|
24
|
+
|
|
25
|
+
export function setMixinBlazeSender(accountId: string, sender: MixinBlazeSender | null): void {
|
|
26
|
+
if (!accountId.trim()) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (sender) {
|
|
30
|
+
blazeSenders.set(accountId, sender);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
blazeSenders.delete(accountId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getMixinBlazeSender(accountId: string): MixinBlazeSender | null {
|
|
37
|
+
return blazeSenders.get(accountId) ?? null;
|
|
38
|
+
}
|
package/src/send-service.ts
CHANGED
|
@@ -2,12 +2,10 @@ import crypto from "crypto";
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
3
3
|
import os from "os";
|
|
4
4
|
import path from "path";
|
|
5
|
-
import { MixinApi } from "@mixin.dev/mixin-node-sdk";
|
|
6
5
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
7
|
-
import type { MixinAccountConfig } from "./config-schema.js";
|
|
8
6
|
import { getAccountConfig } from "./config.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { getMixinBlazeSender, getMixinRuntime } from "./runtime.js";
|
|
8
|
+
import { buildClient, sleep, type SendLog } from "./shared.js";
|
|
11
9
|
|
|
12
10
|
const BASE_DELAY = 1000;
|
|
13
11
|
const MAX_DELAY = 60_000;
|
|
@@ -15,12 +13,6 @@ const MULTIPLIER = 1.5;
|
|
|
15
13
|
const MAX_ERROR_LENGTH = 500;
|
|
16
14
|
const MAX_OUTBOX_FILE_BYTES = 10 * 1024 * 1024;
|
|
17
15
|
|
|
18
|
-
type SendLog = {
|
|
19
|
-
info: (msg: string) => void;
|
|
20
|
-
error: (msg: string, err?: unknown) => void;
|
|
21
|
-
warn: (msg: string) => void;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
16
|
export type MixinSupportedMessageCategory =
|
|
25
17
|
| "PLAIN_TEXT"
|
|
26
18
|
| "PLAIN_POST"
|
|
@@ -139,22 +131,6 @@ const state: {
|
|
|
139
131
|
wakeResolver: null,
|
|
140
132
|
};
|
|
141
133
|
|
|
142
|
-
function sleep(ms: number): Promise<void> {
|
|
143
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function buildClient(config: MixinAccountConfig) {
|
|
147
|
-
return MixinApi({
|
|
148
|
-
keystore: {
|
|
149
|
-
app_id: config.appId!,
|
|
150
|
-
session_id: config.sessionId!,
|
|
151
|
-
server_public_key: config.serverPublicKey!,
|
|
152
|
-
session_private_key: config.sessionPrivateKey!,
|
|
153
|
-
},
|
|
154
|
-
requestConfig: buildRequestConfig(config.proxy),
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
134
|
function guessMimeType(fileName: string): string {
|
|
159
135
|
const ext = path.extname(fileName).toLowerCase();
|
|
160
136
|
switch (ext) {
|
|
@@ -230,6 +206,17 @@ function resolveOutboxPaths(): {
|
|
|
230
206
|
};
|
|
231
207
|
}
|
|
232
208
|
|
|
209
|
+
export function getOutboxPathsSnapshot(): {
|
|
210
|
+
outboxDir: string;
|
|
211
|
+
outboxFile: string;
|
|
212
|
+
} {
|
|
213
|
+
const { outboxDir, outboxFile } = resolveOutboxPaths();
|
|
214
|
+
return {
|
|
215
|
+
outboxDir,
|
|
216
|
+
outboxFile,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
233
220
|
function normalizeErrorMessage(message: string): string {
|
|
234
221
|
if (message.length <= MAX_ERROR_LENGTH) {
|
|
235
222
|
return message;
|
|
@@ -266,7 +253,7 @@ function normalizeEntry(entry: OutboxEntry): OutboxEntry {
|
|
|
266
253
|
};
|
|
267
254
|
}
|
|
268
255
|
|
|
269
|
-
function isStructuredBody(body: string):
|
|
256
|
+
function isStructuredBody(body: string): boolean {
|
|
270
257
|
return body.trim().startsWith("{");
|
|
271
258
|
}
|
|
272
259
|
|
|
@@ -464,6 +451,23 @@ async function attemptSend(entry: OutboxEntry): Promise<void> {
|
|
|
464
451
|
}
|
|
465
452
|
|
|
466
453
|
const dataBase64 = Buffer.from(payloadBody).toString("base64");
|
|
454
|
+
if (!entry.recipientId) {
|
|
455
|
+
const blazeSender = getMixinBlazeSender(entry.accountId);
|
|
456
|
+
if (!blazeSender) {
|
|
457
|
+
throw new Error("group send failed: blaze sender unavailable");
|
|
458
|
+
}
|
|
459
|
+
state.log.info(
|
|
460
|
+
`[mixin] attempt send: transport=blaze, jobId=${entry.jobId}, messageId=${entry.messageId}, conversation=${entry.conversationId}, recipient=none, category=${entry.category}`,
|
|
461
|
+
);
|
|
462
|
+
await blazeSender({
|
|
463
|
+
conversationId: entry.conversationId,
|
|
464
|
+
messageId: entry.messageId,
|
|
465
|
+
category: entry.category,
|
|
466
|
+
dataBase64,
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
467
471
|
const messagePayload: {
|
|
468
472
|
conversation_id: string;
|
|
469
473
|
message_id: string;
|
|
@@ -481,6 +485,10 @@ async function attemptSend(entry: OutboxEntry): Promise<void> {
|
|
|
481
485
|
messagePayload.recipient_id = entry.recipientId;
|
|
482
486
|
}
|
|
483
487
|
|
|
488
|
+
state.log.info(
|
|
489
|
+
`[mixin] attempt send: transport=rest, jobId=${entry.jobId}, messageId=${entry.messageId}, conversation=${entry.conversationId}, recipient=${messagePayload.recipient_id ?? "none"}, category=${entry.category}`,
|
|
490
|
+
);
|
|
491
|
+
|
|
484
492
|
await client.message.sendOne(messagePayload);
|
|
485
493
|
}
|
|
486
494
|
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MixinApi } from "@mixin.dev/mixin-node-sdk";
|
|
2
|
+
import type { MixinAccountConfig } from "./config-schema.js";
|
|
3
|
+
import { buildRequestConfig } from "./proxy.js";
|
|
4
|
+
|
|
5
|
+
export type SendLog = {
|
|
6
|
+
info: (msg: string) => void;
|
|
7
|
+
warn: (msg: string) => void;
|
|
8
|
+
error: (msg: string, err?: unknown) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function sleep(ms: number): Promise<void> {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildClient(config: MixinAccountConfig) {
|
|
16
|
+
return MixinApi({
|
|
17
|
+
keystore: {
|
|
18
|
+
app_id: config.appId!,
|
|
19
|
+
session_id: config.sessionId!,
|
|
20
|
+
server_public_key: config.serverPublicKey!,
|
|
21
|
+
session_private_key: config.sessionPrivateKey!,
|
|
22
|
+
},
|
|
23
|
+
requestConfig: buildRequestConfig(config.proxy),
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import { getAccountConfig, resolveDefaultAccountId } from "./config.js";
|
|
4
|
+
import { getOutboxPathsSnapshot, type OutboxStatus } from "./send-service.js";
|
|
5
|
+
import type { getMixpayStatusSnapshot } from "./mixpay-worker.js";
|
|
6
|
+
|
|
7
|
+
type RuntimeLifecycleSnapshot = {
|
|
8
|
+
running?: boolean | null;
|
|
9
|
+
lastStartAt?: number | null;
|
|
10
|
+
lastStopAt?: number | null;
|
|
11
|
+
lastError?: string | null;
|
|
12
|
+
lastInboundAt?: number | null;
|
|
13
|
+
lastOutboundAt?: number | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type MixinChannelStatusSnapshot = {
|
|
17
|
+
configured?: boolean | null;
|
|
18
|
+
running?: boolean | null;
|
|
19
|
+
lastStartAt?: number | null;
|
|
20
|
+
lastStopAt?: number | null;
|
|
21
|
+
lastError?: string | null;
|
|
22
|
+
defaultAccountId?: string | null;
|
|
23
|
+
outboxDir?: string | null;
|
|
24
|
+
outboxFile?: string | null;
|
|
25
|
+
outboxPending?: number | null;
|
|
26
|
+
mediaMaxMb?: number | null;
|
|
27
|
+
mixpayPendingOrders?: number | null;
|
|
28
|
+
mixpayStoreDir?: string | null;
|
|
29
|
+
mixpayStoreFile?: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type MixinStatusAccount = {
|
|
33
|
+
accountId: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
configured?: boolean;
|
|
37
|
+
config: {
|
|
38
|
+
requireMentionInGroup?: boolean;
|
|
39
|
+
mediaBypassMentionInGroup?: boolean;
|
|
40
|
+
mediaMaxMb?: number;
|
|
41
|
+
audioAutoDetectDuration?: boolean;
|
|
42
|
+
audioSendAsVoiceByDefault?: boolean;
|
|
43
|
+
audioRequireFfprobe?: boolean;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function resolveMixinStatusSnapshot(
|
|
48
|
+
cfg: OpenClawConfig,
|
|
49
|
+
accountId?: string,
|
|
50
|
+
outboxStatus?: OutboxStatus | null,
|
|
51
|
+
mixpayStatus?: Awaited<ReturnType<typeof getMixpayStatusSnapshot>> | null,
|
|
52
|
+
): {
|
|
53
|
+
defaultAccountId: string;
|
|
54
|
+
outboxDir: string;
|
|
55
|
+
outboxFile: string;
|
|
56
|
+
outboxPending: number;
|
|
57
|
+
mediaMaxMb: number | null;
|
|
58
|
+
mixpayPendingOrders: number;
|
|
59
|
+
mixpayStoreDir: string | null;
|
|
60
|
+
mixpayStoreFile: string | null;
|
|
61
|
+
} {
|
|
62
|
+
const defaultAccountId = resolveDefaultAccountId(cfg);
|
|
63
|
+
const resolvedAccountId = accountId ?? defaultAccountId;
|
|
64
|
+
const accountConfig = getAccountConfig(cfg, resolvedAccountId);
|
|
65
|
+
const { outboxDir, outboxFile } = getOutboxPathsSnapshot();
|
|
66
|
+
return {
|
|
67
|
+
defaultAccountId,
|
|
68
|
+
outboxDir,
|
|
69
|
+
outboxFile,
|
|
70
|
+
outboxPending: outboxStatus?.totalPending ?? 0,
|
|
71
|
+
mediaMaxMb: accountConfig.mediaMaxMb ?? null,
|
|
72
|
+
mixpayPendingOrders: mixpayStatus?.pendingOrders ?? 0,
|
|
73
|
+
mixpayStoreDir: mixpayStatus?.storeDir ?? null,
|
|
74
|
+
mixpayStoreFile: mixpayStatus?.storeFile ?? null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildMixinChannelSummary(params: {
|
|
79
|
+
snapshot: MixinChannelStatusSnapshot;
|
|
80
|
+
}) {
|
|
81
|
+
const { snapshot } = params;
|
|
82
|
+
return {
|
|
83
|
+
...buildBaseChannelStatusSummary(snapshot),
|
|
84
|
+
defaultAccountId: snapshot.defaultAccountId ?? null,
|
|
85
|
+
outboxDir: snapshot.outboxDir ?? null,
|
|
86
|
+
outboxFile: snapshot.outboxFile ?? null,
|
|
87
|
+
outboxPending: snapshot.outboxPending ?? 0,
|
|
88
|
+
mediaMaxMb: snapshot.mediaMaxMb ?? null,
|
|
89
|
+
mixpayPendingOrders: snapshot.mixpayPendingOrders ?? 0,
|
|
90
|
+
mixpayStoreDir: snapshot.mixpayStoreDir ?? null,
|
|
91
|
+
mixpayStoreFile: snapshot.mixpayStoreFile ?? null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildMixinAccountSnapshot(params: {
|
|
96
|
+
account: MixinStatusAccount;
|
|
97
|
+
runtime?: RuntimeLifecycleSnapshot | null;
|
|
98
|
+
probe?: unknown;
|
|
99
|
+
defaultAccountId?: string | null;
|
|
100
|
+
outboxPending?: number | null;
|
|
101
|
+
}) {
|
|
102
|
+
const { account, runtime, probe, defaultAccountId, outboxPending } = params;
|
|
103
|
+
return {
|
|
104
|
+
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
|
|
105
|
+
defaultAccountId: defaultAccountId ?? null,
|
|
106
|
+
outboxPending: outboxPending ?? 0,
|
|
107
|
+
requireMentionInGroup: account.config.requireMentionInGroup ?? true,
|
|
108
|
+
mediaBypassMentionInGroup: account.config.mediaBypassMentionInGroup ?? true,
|
|
109
|
+
mediaMaxMb: account.config.mediaMaxMb ?? null,
|
|
110
|
+
audioAutoDetectDuration: account.config.audioAutoDetectDuration ?? true,
|
|
111
|
+
audioSendAsVoiceByDefault: account.config.audioSendAsVoiceByDefault ?? true,
|
|
112
|
+
audioRequireFfprobe: account.config.audioRequireFfprobe ?? false,
|
|
113
|
+
};
|
|
114
|
+
}
|
package/src/decrypt.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 将 Mixin 的 Ed25519 seed 转换为 Curve25519 私钥,并与对端公钥协商出共享密钥
|
|
5
|
-
* @param seedHex 64 字符的 Hex 字符串,对应 session_private_key
|
|
6
|
-
* @param peerPublicKey 32 字节的对端 Curve25519 公钥
|
|
7
|
-
*/
|
|
8
|
-
export function x25519KeyAgreement(seedHex: string, peerPublicKey: Buffer): Buffer {
|
|
9
|
-
// 1. 将 64 字符的 Hex 转换为 32 字节的 seed
|
|
10
|
-
const seedBytes = Buffer.from(seedHex, 'hex');
|
|
11
|
-
if (seedBytes.length !== 32) {
|
|
12
|
-
throw new Error('Invalid Ed25519 seed length, expected 32 bytes.');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// 2. SHA-512 散列
|
|
16
|
-
const hash = crypto.createHash('sha512').update(seedBytes).digest();
|
|
17
|
-
|
|
18
|
-
// 3. 提取前 32 字节并进行 Curve25519 位截断 (Clamping)
|
|
19
|
-
const privateKeyX25519 = Buffer.from(hash.slice(0, 32));
|
|
20
|
-
privateKeyX25519[0] &= 248;
|
|
21
|
-
privateKeyX25519[31] &= 127;
|
|
22
|
-
privateKeyX25519[31] |= 64;
|
|
23
|
-
|
|
24
|
-
const ecdh = crypto.createECDH('x25519');
|
|
25
|
-
ecdh.setPrivateKey(privateKeyX25519);
|
|
26
|
-
|
|
27
|
-
return ecdh.computeSecret(peerPublicKey);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 解密 Mixin ENCRYPTED_TEXT 消息 (对应 Go SDK DecryptMessageData)
|
|
32
|
-
* @param data Base64 编码的加密数据
|
|
33
|
-
* @param sessionId 机器人的 session_id
|
|
34
|
-
* @param privateKey 机器人的 ed25519 私钥(hex 格式,实为 seed)
|
|
35
|
-
* @returns 解密后的明文,失败返回 null
|
|
36
|
-
*/
|
|
37
|
-
export function decryptMessageData(
|
|
38
|
-
data: string,
|
|
39
|
-
sessionId: string,
|
|
40
|
-
privateKey: string
|
|
41
|
-
): string | null {
|
|
42
|
-
try {
|
|
43
|
-
// 1. Base64 解码,处理可能的 URL-safe Base64
|
|
44
|
-
let base64 = data.replace(/-/g, '+').replace(/_/g, '/');
|
|
45
|
-
while (base64.length % 4) {
|
|
46
|
-
base64 += '=';
|
|
47
|
-
}
|
|
48
|
-
const encryptedBytes = Buffer.from(base64, 'base64');
|
|
49
|
-
|
|
50
|
-
// 验证最小长度: version(1) + sessionCount(2) + senderPubKey(32) + nonce(12)
|
|
51
|
-
if (encryptedBytes.length < 1 + 2 + 32 + 12) {
|
|
52
|
-
console.error('[mixin decrypt] data too short:', encryptedBytes.length);
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// 解析消息结构
|
|
57
|
-
const version = encryptedBytes[0];
|
|
58
|
-
if (version !== 1) {
|
|
59
|
-
console.error('[mixin decrypt] unsupported version:', version);
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const sessionCount = encryptedBytes.readUInt16LE(1);
|
|
64
|
-
let offset = 3;
|
|
65
|
-
|
|
66
|
-
// 2. 提取发送者公钥 (已经是 Curve25519)
|
|
67
|
-
const senderPublicKey = encryptedBytes.slice(offset, offset + 32);
|
|
68
|
-
offset += 32;
|
|
69
|
-
|
|
70
|
-
// 查找匹配的 session
|
|
71
|
-
const sessionIdBuffer = Buffer.from(sessionId.replace(/-/g, ''), 'hex');
|
|
72
|
-
|
|
73
|
-
let sessionData: Buffer | null = null;
|
|
74
|
-
for (let i = 0; i < sessionCount; i++) {
|
|
75
|
-
const sessionIdInMsg = encryptedBytes.slice(offset, offset + 16);
|
|
76
|
-
|
|
77
|
-
if (sessionIdInMsg.equals(sessionIdBuffer)) {
|
|
78
|
-
sessionData = encryptedBytes.slice(offset + 16, offset + 64);
|
|
79
|
-
break; // 暂不中断读取,只取我们自己的 session 块
|
|
80
|
-
}
|
|
81
|
-
offset += 64;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (!sessionData) {
|
|
85
|
-
console.error('[mixin decrypt] session not found');
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 3. 计算 Shared Secret
|
|
90
|
-
const sharedSecret = x25519KeyAgreement(privateKey, senderPublicKey);
|
|
91
|
-
|
|
92
|
-
// 4. 解密 Message Key (AES-256-CBC)
|
|
93
|
-
// sessionData 的前 16 字节为 IV,后 32 字节为加密后的 key
|
|
94
|
-
const sessionIv = sessionData.slice(0, 16);
|
|
95
|
-
const encryptedKey = sessionData.slice(16, 48);
|
|
96
|
-
|
|
97
|
-
const decipherKey = crypto.createDecipheriv('aes-256-cbc', sharedSecret, sessionIv);
|
|
98
|
-
// Mixin SDK 这里加了 padding 处理。如果后续失败,尝试 decipherKey.setAutoPadding(false);
|
|
99
|
-
const rawMessageKey = Buffer.concat([decipherKey.update(encryptedKey), decipherKey.final()]);
|
|
100
|
-
|
|
101
|
-
// 取前 16 字节!
|
|
102
|
-
const messageKey = rawMessageKey.slice(0, 16);
|
|
103
|
-
|
|
104
|
-
// 5. 获取 Nonce 和 密文
|
|
105
|
-
const prefixSize = 3 + 32 + sessionCount * 64;
|
|
106
|
-
const nonce = encryptedBytes.slice(prefixSize, prefixSize + 12); // 注意这里是 12 字节!!!
|
|
107
|
-
const encryptedText = encryptedBytes.slice(prefixSize + 12);
|
|
108
|
-
|
|
109
|
-
// 6. 解密消息体 (AES-128-GCM)
|
|
110
|
-
// 对于 GCM,还需要分离出 authentication tag (后 16 字节)
|
|
111
|
-
const tag = encryptedText.slice(-16);
|
|
112
|
-
const ciphertext = encryptedText.slice(0, -16);
|
|
113
|
-
|
|
114
|
-
const decipherGcm = crypto.createDecipheriv('aes-128-gcm', messageKey, nonce);
|
|
115
|
-
decipherGcm.setAuthTag(tag);
|
|
116
|
-
|
|
117
|
-
let decryptedText = decipherGcm.update(ciphertext);
|
|
118
|
-
decryptedText = Buffer.concat([decryptedText, decipherGcm.final()]);
|
|
119
|
-
|
|
120
|
-
return decryptedText.toString('utf8');
|
|
121
|
-
|
|
122
|
-
} catch (error) {
|
|
123
|
-
console.error('[mixin decrypt] error:', error);
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
}
|