@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.
@@ -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 parseExplicitTemplate(text: string): MixinReplyPlan | null {
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
- return null;
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 buildMixinReplyPlan(text: string): MixinReplyPlan | null {
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
- kind: "buttons",
314
- intro: intro || undefined,
315
- buttons: buildButtons(links),
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
- kind: "card",
324
- card: {
325
- title,
326
- description,
327
- action: links[0].url,
328
- shareable: true,
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
+ }
@@ -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 { buildRequestConfig } from "./proxy.js";
10
- import { getMixinRuntime } from "./runtime.js";
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): body is 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
- }