@invago/mixin 1.0.7
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 +281 -0
- package/README.zh-CN.md +291 -0
- package/eslint.config.mjs +25 -0
- package/index.ts +28 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +1 -0
- package/src/blaze-service.ts +167 -0
- package/src/channel.ts +199 -0
- package/src/config-schema.ts +43 -0
- package/src/config.ts +63 -0
- package/src/crypto.ts +93 -0
- package/src/decrypt.ts +126 -0
- package/src/inbound-handler.ts +336 -0
- package/src/proxy.ts +42 -0
- package/src/reply-format.ts +281 -0
- package/src/runtime.ts +12 -0
- package/src/send-service.ts +553 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeMessage,
|
|
3
|
+
sendRaw,
|
|
4
|
+
signAccessToken,
|
|
5
|
+
type BlazeHandler,
|
|
6
|
+
type BlazeOptions,
|
|
7
|
+
} from "@mixin.dev/mixin-node-sdk";
|
|
8
|
+
import WebSocket from "ws";
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
import type { MixinAccountConfig } from "./config-schema.js";
|
|
11
|
+
import { createProxyAgent } from "./proxy.js";
|
|
12
|
+
|
|
13
|
+
type SendLog = {
|
|
14
|
+
info: (msg: string) => void;
|
|
15
|
+
error: (msg: string, err?: unknown) => void;
|
|
16
|
+
warn: (msg: string) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function buildKeystore(config: MixinAccountConfig) {
|
|
20
|
+
return {
|
|
21
|
+
app_id: config.appId!,
|
|
22
|
+
session_id: config.sessionId!,
|
|
23
|
+
server_public_key: config.serverPublicKey!,
|
|
24
|
+
session_private_key: config.sessionPrivateKey!,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function dispatchMessage(handler: BlazeHandler, msg: any): Promise<void> {
|
|
29
|
+
if (msg.source === "ACKNOWLEDGE_MESSAGE_RECEIPT" && handler.onAckReceipt) {
|
|
30
|
+
await handler.onAckReceipt(msg);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (msg.category === "SYSTEM_CONVERSATION" && handler.onConversation) {
|
|
35
|
+
await handler.onConversation(msg);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (msg.category === "SYSTEM_ACCOUNT_SNAPSHOT" && handler.onTransfer) {
|
|
40
|
+
await handler.onTransfer(msg);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await handler.onMessage(msg);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runBlazeLoop(params: {
|
|
48
|
+
config: MixinAccountConfig;
|
|
49
|
+
options?: BlazeOptions;
|
|
50
|
+
handler: BlazeHandler;
|
|
51
|
+
log: SendLog;
|
|
52
|
+
abortSignal?: AbortSignal;
|
|
53
|
+
}): Promise<void> {
|
|
54
|
+
const { config, options, handler, log, abortSignal } = params;
|
|
55
|
+
const keystore = buildKeystore(config);
|
|
56
|
+
const jwtToken = signAccessToken("GET", "/", "", crypto.randomUUID(), keystore) || "";
|
|
57
|
+
const agent = createProxyAgent(config.proxy);
|
|
58
|
+
|
|
59
|
+
await new Promise<void>((resolve, reject) => {
|
|
60
|
+
let ws: WebSocket | undefined;
|
|
61
|
+
let opened = false;
|
|
62
|
+
let settled = false;
|
|
63
|
+
let pingTimeout: NodeJS.Timeout | null = null;
|
|
64
|
+
|
|
65
|
+
const cleanup = () => {
|
|
66
|
+
if (pingTimeout) {
|
|
67
|
+
clearTimeout(pingTimeout);
|
|
68
|
+
pingTimeout = null;
|
|
69
|
+
}
|
|
70
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const finish = (err?: unknown) => {
|
|
74
|
+
if (settled) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
settled = true;
|
|
78
|
+
cleanup();
|
|
79
|
+
if (err) {
|
|
80
|
+
reject(err);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
resolve();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const terminate = () => {
|
|
87
|
+
if (!ws) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
ws.terminate();
|
|
91
|
+
ws = undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const heartbeat = () => {
|
|
95
|
+
if (pingTimeout) {
|
|
96
|
+
clearTimeout(pingTimeout);
|
|
97
|
+
}
|
|
98
|
+
pingTimeout = setTimeout(() => {
|
|
99
|
+
terminate();
|
|
100
|
+
}, 30_000);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onAbort = () => {
|
|
104
|
+
terminate();
|
|
105
|
+
finish();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
ws = new WebSocket("wss://blaze.mixin.one", "Mixin-Blaze-1", {
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: `Bearer ${jwtToken}`,
|
|
111
|
+
},
|
|
112
|
+
handshakeTimeout: 3000,
|
|
113
|
+
agent,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
abortSignal?.addEventListener("abort", onAbort);
|
|
117
|
+
|
|
118
|
+
ws.on("open", () => {
|
|
119
|
+
opened = true;
|
|
120
|
+
heartbeat();
|
|
121
|
+
void sendRaw(ws!, {
|
|
122
|
+
id: crypto.randomUUID(),
|
|
123
|
+
action: "LIST_PENDING_MESSAGES",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
ws.on("ping", () => {
|
|
128
|
+
heartbeat();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
ws.on("message", async (data) => {
|
|
132
|
+
try {
|
|
133
|
+
const msg = decodeMessage(data as Uint8Array, options ?? { parse: false, syncAck: false });
|
|
134
|
+
if (!msg) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (options?.syncAck && msg.message_id) {
|
|
139
|
+
await sendRaw(ws!, {
|
|
140
|
+
id: crypto.randomUUID(),
|
|
141
|
+
action: "ACKNOWLEDGE_MESSAGE_RECEIPT",
|
|
142
|
+
params: {
|
|
143
|
+
message_id: msg.message_id,
|
|
144
|
+
status: "READ",
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await dispatchMessage(handler, msg);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log.error("[mixin] blaze message error", err);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
ws.on("close", () => {
|
|
156
|
+
finish();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
ws.on("error", (err) => {
|
|
160
|
+
if (!opened) {
|
|
161
|
+
finish(err);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
log.error("[mixin] blaze websocket error", err);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { ChannelGatewayContext, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import { runBlazeLoop } from "./blaze-service.js";
|
|
4
|
+
import { MixinConfigSchema } from "./config-schema.js";
|
|
5
|
+
import { describeAccount, isConfigured, listAccountIds, resolveAccount } from "./config.js";
|
|
6
|
+
import { handleMixinMessage, type MixinInboundMessage } from "./inbound-handler.js";
|
|
7
|
+
import { sendTextMessage, startSendWorker } from "./send-service.js";
|
|
8
|
+
|
|
9
|
+
type ResolvedMixinAccount = ReturnType<typeof resolveAccount>;
|
|
10
|
+
|
|
11
|
+
const BASE_DELAY = 1000;
|
|
12
|
+
const MAX_DELAY = 3000;
|
|
13
|
+
const MULTIPLIER = 1.5;
|
|
14
|
+
|
|
15
|
+
async function sleep(ms: number): Promise<void> {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function maskKey(key: string): string {
|
|
20
|
+
if (!key || key.length < 8) {
|
|
21
|
+
return "****";
|
|
22
|
+
}
|
|
23
|
+
return key.slice(0, 4) + "****" + key.slice(-4);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const mixinPlugin = {
|
|
27
|
+
id: "mixin",
|
|
28
|
+
|
|
29
|
+
meta: {
|
|
30
|
+
id: "mixin",
|
|
31
|
+
label: "Mixin Messenger",
|
|
32
|
+
selectionLabel: "Mixin Messenger (Blaze WebSocket)",
|
|
33
|
+
docsPath: "/channels/mixin",
|
|
34
|
+
blurb: "Mixin Messenger channel via Blaze WebSocket",
|
|
35
|
+
aliases: ["mixin-messenger", "mixin"],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
configSchema: buildChannelConfigSchema(MixinConfigSchema),
|
|
39
|
+
|
|
40
|
+
capabilities: {
|
|
41
|
+
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
|
|
42
|
+
reactions: false,
|
|
43
|
+
threads: false,
|
|
44
|
+
media: false,
|
|
45
|
+
nativeCommands: false,
|
|
46
|
+
blockStreaming: false,
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
config: {
|
|
50
|
+
listAccountIds,
|
|
51
|
+
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
|
52
|
+
resolveAccount(cfg, accountId ?? undefined),
|
|
53
|
+
defaultAccountId: () => "default",
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
security: {
|
|
57
|
+
resolveDmPolicy: ({ account, accountId }: { account: ResolvedMixinAccount; accountId?: string | null }) => {
|
|
58
|
+
const allowFrom = account.config.allowFrom ?? [];
|
|
59
|
+
const basePath = accountId && accountId !== "default" ? `.accounts.${accountId}` : "";
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
policy: "allowlist" as const,
|
|
63
|
+
allowFrom,
|
|
64
|
+
allowFromPath: `channels.mixin${basePath}.allowFrom`,
|
|
65
|
+
approveHint: allowFrom.length > 0
|
|
66
|
+
? `已配置白名单用户数 ${allowFrom.length},将用户的 Mixin UUID 添加到 allowFrom 列表即可授权`
|
|
67
|
+
: "将用户的 Mixin UUID 添加到 allowFrom 列表即可授权",
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
outbound: {
|
|
73
|
+
deliveryMode: "direct" as const,
|
|
74
|
+
|
|
75
|
+
sendText: async (ctx: {
|
|
76
|
+
cfg: OpenClawConfig;
|
|
77
|
+
to: string;
|
|
78
|
+
text: string;
|
|
79
|
+
accountId?: string | null;
|
|
80
|
+
}) => {
|
|
81
|
+
const id = ctx.accountId ?? "default";
|
|
82
|
+
const result = await sendTextMessage(ctx.cfg, id, ctx.to, undefined, ctx.text);
|
|
83
|
+
if (result.ok) {
|
|
84
|
+
return { channel: "mixin", messageId: result.messageId ?? ctx.to };
|
|
85
|
+
}
|
|
86
|
+
throw new Error(result.error ?? "sendText failed");
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
gateway: {
|
|
91
|
+
startAccount: async (ctx: ChannelGatewayContext<ResolvedMixinAccount>): Promise<unknown> => {
|
|
92
|
+
const { account, cfg, abortSignal } = ctx;
|
|
93
|
+
const log = (ctx as any).log ?? {
|
|
94
|
+
info: (m: string) => console.log(`[mixin] ${m}`),
|
|
95
|
+
warn: (m: string) => console.warn(`[mixin] ${m}`),
|
|
96
|
+
error: (m: string, e?: unknown) => console.error(`[mixin] ${m}`, e),
|
|
97
|
+
};
|
|
98
|
+
const accountId = account.accountId;
|
|
99
|
+
const config = account.config;
|
|
100
|
+
|
|
101
|
+
await startSendWorker(cfg, log);
|
|
102
|
+
|
|
103
|
+
let stopped = false;
|
|
104
|
+
const stop = () => {
|
|
105
|
+
stopped = true;
|
|
106
|
+
};
|
|
107
|
+
abortSignal?.addEventListener("abort", stop);
|
|
108
|
+
|
|
109
|
+
let attempt = 1;
|
|
110
|
+
let delay = BASE_DELAY;
|
|
111
|
+
|
|
112
|
+
const runLoop = async () => {
|
|
113
|
+
while (!stopped) {
|
|
114
|
+
try {
|
|
115
|
+
log.info(`connecting to Mixin Blaze (attempt ${attempt})`);
|
|
116
|
+
log.info(`config: appId=${maskKey(config.appId!)}, sessionId=${maskKey(config.sessionId!)}`);
|
|
117
|
+
|
|
118
|
+
await runBlazeLoop({
|
|
119
|
+
config,
|
|
120
|
+
options: { parse: false, syncAck: true },
|
|
121
|
+
log,
|
|
122
|
+
abortSignal,
|
|
123
|
+
handler: {
|
|
124
|
+
onMessage: async (rawMsg: any) => {
|
|
125
|
+
if (stopped) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!rawMsg || !rawMsg.message_id) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!rawMsg.user_id || rawMsg.user_id === config.appId) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isDirect = rawMsg.conversation_id === undefined
|
|
136
|
+
? true
|
|
137
|
+
: !rawMsg.representative_id;
|
|
138
|
+
|
|
139
|
+
const msg: MixinInboundMessage = {
|
|
140
|
+
conversationId: rawMsg.conversation_id ?? "",
|
|
141
|
+
userId: rawMsg.user_id,
|
|
142
|
+
messageId: rawMsg.message_id,
|
|
143
|
+
category: rawMsg.category ?? "PLAIN_TEXT",
|
|
144
|
+
data: rawMsg.data_base64 ?? rawMsg.data ?? "",
|
|
145
|
+
createdAt: rawMsg.created_at ?? new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await handleMixinMessage({ cfg, accountId, msg, isDirect, log });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log.error(`error handling message ${msg.messageId}`, err);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (stopped) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
attempt = 1;
|
|
162
|
+
delay = BASE_DELAY;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (stopped) {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
168
|
+
log.error(`connection error: ${errorMsg}`, err);
|
|
169
|
+
log.warn(`retrying in ${delay}ms (attempt ${attempt})`);
|
|
170
|
+
await sleep(delay);
|
|
171
|
+
delay = Math.min(delay * MULTIPLIER, MAX_DELAY);
|
|
172
|
+
attempt++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
log.info("gateway stopped");
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await runLoop();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
183
|
+
log.error(`[internal] unexpected loop error: ${msg}`, err);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { stop };
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
status: {
|
|
191
|
+
defaultRuntime: {
|
|
192
|
+
accountId: "default",
|
|
193
|
+
running: false,
|
|
194
|
+
status: "stopped" as const,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export { describeAccount, isConfigured };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const MixinProxyConfigSchema = z.object({
|
|
4
|
+
enabled: z.boolean().optional().default(false),
|
|
5
|
+
url: z.string().optional(),
|
|
6
|
+
username: z.string().optional(),
|
|
7
|
+
password: z.string().optional(),
|
|
8
|
+
}).superRefine((value, ctx) => {
|
|
9
|
+
if (!value.enabled) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!value.url) {
|
|
14
|
+
ctx.addIssue({
|
|
15
|
+
code: z.ZodIssueCode.custom,
|
|
16
|
+
message: "proxy.url is required when proxy.enabled is true",
|
|
17
|
+
path: ["url"],
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type MixinProxyConfig = z.infer<typeof MixinProxyConfigSchema>;
|
|
23
|
+
|
|
24
|
+
export const MixinAccountConfigSchema = z.object({
|
|
25
|
+
name: z.string().optional(),
|
|
26
|
+
enabled: z.boolean().optional().default(true),
|
|
27
|
+
appId: z.string().optional(),
|
|
28
|
+
sessionId: z.string().optional(),
|
|
29
|
+
serverPublicKey: z.string().optional(),
|
|
30
|
+
sessionPrivateKey: z.string().optional(),
|
|
31
|
+
allowFrom: z.array(z.string()).optional().default([]),
|
|
32
|
+
requireMentionInGroup: z.boolean().optional().default(true),
|
|
33
|
+
debug: z.boolean().optional().default(false),
|
|
34
|
+
proxy: MixinProxyConfigSchema.optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type MixinAccountConfig = z.infer<typeof MixinAccountConfigSchema>;
|
|
38
|
+
|
|
39
|
+
export const MixinConfigSchema: z.ZodTypeAny = MixinAccountConfigSchema.extend({
|
|
40
|
+
accounts: z.record(z.string(), MixinAccountConfigSchema.optional()).optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export type MixinConfig = z.infer<typeof MixinConfigSchema>;
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { MixinAccountConfigSchema, type MixinAccountConfig, type MixinConfig } from "./config-schema.js";
|
|
3
|
+
|
|
4
|
+
function getRawConfig(cfg: OpenClawConfig): any {
|
|
5
|
+
return (cfg.channels as Record<string, unknown>)?.mixin ?? {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function listAccountIds(cfg: OpenClawConfig): string[] {
|
|
9
|
+
const raw = getRawConfig(cfg);
|
|
10
|
+
if (raw.accounts && Object.keys(raw.accounts).length > 0) {
|
|
11
|
+
return Object.keys(raw.accounts);
|
|
12
|
+
}
|
|
13
|
+
return ["default"];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getAccountConfig(cfg: OpenClawConfig, accountId?: string): MixinAccountConfig {
|
|
17
|
+
const raw = getRawConfig(cfg);
|
|
18
|
+
let accountRaw: Partial<MixinAccountConfig>;
|
|
19
|
+
|
|
20
|
+
if (accountId && accountId !== "default" && raw.accounts?.[accountId]) {
|
|
21
|
+
accountRaw = raw.accounts[accountId] as Partial<MixinAccountConfig>;
|
|
22
|
+
} else {
|
|
23
|
+
accountRaw = raw as MixinConfig as Partial<MixinAccountConfig>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = MixinAccountConfigSchema.safeParse(accountRaw);
|
|
27
|
+
if (result.success) return result.data;
|
|
28
|
+
return MixinAccountConfigSchema.parse({});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveAccount(cfg: OpenClawConfig, accountId?: string) {
|
|
32
|
+
const id = accountId ?? "default";
|
|
33
|
+
const config = getAccountConfig(cfg, id);
|
|
34
|
+
const configured = Boolean(config.appId && config.sessionId && config.serverPublicKey && config.sessionPrivateKey);
|
|
35
|
+
return {
|
|
36
|
+
accountId: id,
|
|
37
|
+
enabled: config.enabled !== false,
|
|
38
|
+
configured,
|
|
39
|
+
name: config.name,
|
|
40
|
+
appId: config.appId,
|
|
41
|
+
sessionId: config.sessionId,
|
|
42
|
+
serverPublicKey: config.serverPublicKey,
|
|
43
|
+
sessionPrivateKey: config.sessionPrivateKey,
|
|
44
|
+
allowFrom: config.allowFrom,
|
|
45
|
+
requireMentionInGroup: config.requireMentionInGroup,
|
|
46
|
+
debug: config.debug,
|
|
47
|
+
config,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isConfigured(account: ReturnType<typeof resolveAccount>): boolean {
|
|
52
|
+
return account.configured;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function describeAccount(account: ReturnType<typeof resolveAccount>) {
|
|
56
|
+
const { config, accountId } = account;
|
|
57
|
+
return {
|
|
58
|
+
accountId,
|
|
59
|
+
name: config.name ?? accountId,
|
|
60
|
+
configured: account.configured,
|
|
61
|
+
enabled: account.enabled,
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { x25519, ed25519 } from "@noble/curves/ed25519.js";
|
|
3
|
+
|
|
4
|
+
function uuidToBuffer(uuid: string): Buffer {
|
|
5
|
+
return Buffer.from(uuid.replace(/-/g, ""), "hex");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function aes256CbcDecrypt(key: Buffer, iv: Buffer, ciphertext: Buffer): Buffer {
|
|
9
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
10
|
+
decipher.setAutoPadding(false);
|
|
11
|
+
const decrypted = decipher.update(ciphertext);
|
|
12
|
+
const final = Buffer.concat([decrypted, decipher.final()]);
|
|
13
|
+
const padLen = final[final.length - 1];
|
|
14
|
+
if (padLen > 0 && padLen <= 16) {
|
|
15
|
+
return final.slice(0, final.length - padLen);
|
|
16
|
+
}
|
|
17
|
+
return final;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function decryptMixinMessage(
|
|
21
|
+
encryptedBase64: string,
|
|
22
|
+
sessionPrivateKeyHex: string,
|
|
23
|
+
mySessionIdStr: string
|
|
24
|
+
): string | null {
|
|
25
|
+
try {
|
|
26
|
+
let b64 = encryptedBase64.replace(/-/g, "+").replace(/_/g, "/");
|
|
27
|
+
while (b64.length % 4 !== 0) b64 += "=";
|
|
28
|
+
const encryptedData = Buffer.from(b64, "base64");
|
|
29
|
+
|
|
30
|
+
const version = encryptedData[0];
|
|
31
|
+
if (version !== 1) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const receiversCount = encryptedData.readUInt16LE(1);
|
|
36
|
+
const senderCurve25519Pub = encryptedData.slice(3, 35);
|
|
37
|
+
|
|
38
|
+
const seedBytes = Buffer.from(sessionPrivateKeyHex, "hex");
|
|
39
|
+
const myCurve25519Priv = ed25519.utils.toMontgomerySecret(seedBytes);
|
|
40
|
+
const sharedSecret = Buffer.from(x25519.getSharedSecret(myCurve25519Priv, senderCurve25519Pub));
|
|
41
|
+
|
|
42
|
+
let cursor = 35;
|
|
43
|
+
const mySessionIdBuffer = mySessionIdStr ? uuidToBuffer(mySessionIdStr) : null;
|
|
44
|
+
let ivForKey: Buffer | null = null;
|
|
45
|
+
let encryptedMessageKey: Buffer | null = null;
|
|
46
|
+
let found = false;
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < receiversCount; i++) {
|
|
49
|
+
if (cursor + 64 > encryptedData.length) break;
|
|
50
|
+
|
|
51
|
+
const blockSessionId = encryptedData.slice(cursor, cursor + 16);
|
|
52
|
+
|
|
53
|
+
if (!mySessionIdBuffer || blockSessionId.equals(mySessionIdBuffer) || i === 0) {
|
|
54
|
+
ivForKey = encryptedData.slice(cursor + 16, cursor + 32);
|
|
55
|
+
encryptedMessageKey = encryptedData.slice(cursor + 32, cursor + 64);
|
|
56
|
+
found = true;
|
|
57
|
+
if (mySessionIdBuffer && blockSessionId.equals(mySessionIdBuffer)) break;
|
|
58
|
+
}
|
|
59
|
+
cursor += 64;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!found || !ivForKey || !encryptedMessageKey) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const paddedMessageKey = aes256CbcDecrypt(sharedSecret, ivForKey, encryptedMessageKey);
|
|
67
|
+
const messageKey16 = paddedMessageKey.slice(0, 16);
|
|
68
|
+
|
|
69
|
+
cursor = 35 + receiversCount * 64;
|
|
70
|
+
|
|
71
|
+
const gcmNonce = encryptedData.slice(cursor, cursor + 12);
|
|
72
|
+
cursor += 12;
|
|
73
|
+
|
|
74
|
+
const gcmPayload = encryptedData.slice(cursor);
|
|
75
|
+
|
|
76
|
+
if (gcmPayload.length < 16) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const gcmCiphertext = gcmPayload.slice(0, gcmPayload.length - 16);
|
|
81
|
+
const gcmAuthTag = gcmPayload.slice(gcmPayload.length - 16);
|
|
82
|
+
|
|
83
|
+
const decipher = crypto.createDecipheriv("aes-128-gcm", messageKey16, gcmNonce);
|
|
84
|
+
decipher.setAuthTag(gcmAuthTag);
|
|
85
|
+
|
|
86
|
+
let decryptedText = decipher.update(gcmCiphertext, undefined, "utf8");
|
|
87
|
+
decryptedText += decipher.final("utf8");
|
|
88
|
+
|
|
89
|
+
return decryptedText;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/decrypt.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
}
|