@z-qinghui/migpt-claw 1.0.0
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/LICENSE +21 -0
- package/README.md +690 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/setup-entry.d.ts +3 -0
- package/dist/setup-entry.js +7 -0
- package/dist/setup-entry.js.map +1 -0
- package/dist/src/channel.d.ts +10 -0
- package/dist/src/channel.js +444 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/config.d.ts +125 -0
- package/dist/src/config.js +146 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/message.d.ts +51 -0
- package/dist/src/message.js +145 -0
- package/dist/src/message.js.map +1 -0
- package/dist/src/mi/account.d.ts +5 -0
- package/dist/src/mi/account.js +162 -0
- package/dist/src/mi/account.js.map +1 -0
- package/dist/src/mi/common.d.ts +15 -0
- package/dist/src/mi/common.js +80 -0
- package/dist/src/mi/common.js.map +1 -0
- package/dist/src/mi/index.d.ts +4 -0
- package/dist/src/mi/index.js +10 -0
- package/dist/src/mi/index.js.map +1 -0
- package/dist/src/mi/mina.d.ts +66 -0
- package/dist/src/mi/mina.js +225 -0
- package/dist/src/mi/mina.js.map +1 -0
- package/dist/src/mi/miot.d.ts +35 -0
- package/dist/src/mi/miot.js +168 -0
- package/dist/src/mi/miot.js.map +1 -0
- package/dist/src/mi/typing.d.ts +90 -0
- package/dist/src/mi/typing.js +1 -0
- package/dist/src/mi/typing.js.map +1 -0
- package/dist/src/onboarding.d.ts +5 -0
- package/dist/src/onboarding.js +118 -0
- package/dist/src/onboarding.js.map +1 -0
- package/dist/src/openclaw-plugin-sdk.d.d.ts +185 -0
- package/dist/src/openclaw-plugin-sdk.d.js +1 -0
- package/dist/src/openclaw-plugin-sdk.d.js.map +1 -0
- package/dist/src/outbound.d.ts +5 -0
- package/dist/src/outbound.js +108 -0
- package/dist/src/outbound.js.map +1 -0
- package/dist/src/runtime.d.ts +6 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/service.d.ts +70 -0
- package/dist/src/service.js +200 -0
- package/dist/src/service.js.map +1 -0
- package/dist/src/speaker.d.ts +62 -0
- package/dist/src/speaker.js +211 -0
- package/dist/src/speaker.js.map +1 -0
- package/dist/src/tts/mimo.d.ts +50 -0
- package/dist/src/tts/mimo.js +214 -0
- package/dist/src/tts/mimo.js.map +1 -0
- package/dist/src/types.d.ts +30 -0
- package/dist/src/types.js +1 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/codec.d.ts +31 -0
- package/dist/src/utils/codec.js +144 -0
- package/dist/src/utils/codec.js.map +1 -0
- package/dist/src/utils/debug.d.ts +10 -0
- package/dist/src/utils/debug.js +15 -0
- package/dist/src/utils/debug.js.map +1 -0
- package/dist/src/utils/hash.d.ts +40 -0
- package/dist/src/utils/hash.js +75 -0
- package/dist/src/utils/hash.js.map +1 -0
- package/dist/src/utils/http.d.ts +24 -0
- package/dist/src/utils/http.js +151 -0
- package/dist/src/utils/http.js.map +1 -0
- package/dist/src/utils/index.d.ts +6 -0
- package/dist/src/utils/index.js +10 -0
- package/dist/src/utils/index.js.map +1 -0
- package/dist/src/utils/io.d.ts +26 -0
- package/dist/src/utils/io.js +53 -0
- package/dist/src/utils/io.js.map +1 -0
- package/dist/src/utils/parse.d.ts +26 -0
- package/dist/src/utils/parse.js +51 -0
- package/dist/src/utils/parse.js.map +1 -0
- package/index.ts +26 -0
- package/openclaw.plugin.json +344 -0
- package/package.json +106 -0
- package/setup-entry.ts +12 -0
- package/skills/migpt-volume/SKILL.md +182 -0
- package/skills/migpt-volume/index.ts +50 -0
- package/src/channel.ts +519 -0
- package/src/config.ts +299 -0
- package/src/message.ts +186 -0
- package/src/mi/account.ts +184 -0
- package/src/mi/common.ts +105 -0
- package/src/mi/index.ts +4 -0
- package/src/mi/mina.ts +261 -0
- package/src/mi/miot.ts +193 -0
- package/src/mi/typing.ts +93 -0
- package/src/onboarding.ts +136 -0
- package/src/openclaw-plugin-sdk.d.ts +185 -0
- package/src/outbound.ts +137 -0
- package/src/runtime.ts +14 -0
- package/src/service.ts +246 -0
- package/src/speaker.ts +264 -0
- package/src/tts/mimo.ts +300 -0
- package/src/types.ts +34 -0
- package/src/utils/codec.ts +206 -0
- package/src/utils/debug.ts +16 -0
- package/src/utils/hash.ts +104 -0
- package/src/utils/http.ts +193 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/io.ts +68 -0
- package/src/utils/parse.ts +64 -0
- package/tsconfig.json +25 -0
package/src/tts/mimo.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MiMo-V2.5-TT TTS Provider
|
|
3
|
+
*
|
|
4
|
+
* 使用小米 MiMo TTS API 生成语音,通过本地 HTTP 服务器提供给音箱播放。
|
|
5
|
+
* API 文档: https://mimo.mi.com/docs/zh-CN/quick-start/usage-guide/multimodal-understanding/speech-synthesis-v2.5
|
|
6
|
+
*/
|
|
7
|
+
import { createServer, type Server } from 'node:http';
|
|
8
|
+
import { writeFile, mkdir, rm } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
export interface MiMoTTSConfig {
|
|
14
|
+
/** MiMo API Key */
|
|
15
|
+
apiKey: string;
|
|
16
|
+
/** MiMo API Base URL,默认 https://api.xiaomimimo.com/v1 */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** TTS 模型,默认 mimo-v2.5-tts */
|
|
19
|
+
model?: string;
|
|
20
|
+
/** 预设音色 ID,默认 mimo_default */
|
|
21
|
+
voice?: string;
|
|
22
|
+
/** 风格指令(放在 user role 中) */
|
|
23
|
+
style?: string;
|
|
24
|
+
/** 是否启用流式传输(减少首字延迟),默认 true */
|
|
25
|
+
stream?: boolean;
|
|
26
|
+
/** 本地服务器监听端口,0 = 自动分配 */
|
|
27
|
+
port?: number;
|
|
28
|
+
/** 本地服务器监听地址,默认 0.0.0.0 */
|
|
29
|
+
host?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class MiMoTTS {
|
|
33
|
+
private _server: Server | null = null;
|
|
34
|
+
private _serverUrl = '';
|
|
35
|
+
private _audioDir = '';
|
|
36
|
+
private _config: Required<MiMoTTSConfig>;
|
|
37
|
+
private _ready = false;
|
|
38
|
+
|
|
39
|
+
constructor(config: MiMoTTSConfig) {
|
|
40
|
+
this._config = {
|
|
41
|
+
apiKey: config.apiKey,
|
|
42
|
+
baseUrl: config.baseUrl ?? 'https://api.xiaomimimo.com/v1',
|
|
43
|
+
model: config.model ?? 'mimo-v2.5-tts',
|
|
44
|
+
voice: config.voice ?? 'mimo_default',
|
|
45
|
+
style: config.style ?? '',
|
|
46
|
+
stream: config.stream ?? true,
|
|
47
|
+
port: config.port ?? 0,
|
|
48
|
+
host: config.host ?? '0.0.0.0',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 初始化:创建临时目录 + 启动 HTTP 服务器
|
|
54
|
+
*/
|
|
55
|
+
async init(): Promise<boolean> {
|
|
56
|
+
if (this._ready) return true;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
this._audioDir = join(tmpdir(), 'migpt-claw-tts', randomUUID());
|
|
60
|
+
await mkdir(this._audioDir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
this._server = createServer((req, res) => {
|
|
63
|
+
const url = decodeURIComponent(req.url ?? '');
|
|
64
|
+
if (!url.startsWith('/audio/')) {
|
|
65
|
+
res.writeHead(404);
|
|
66
|
+
res.end('Not Found');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const filePath = join(this._audioDir, url.replace('/audio/', ''));
|
|
71
|
+
serveWavFile(filePath, res).catch(() => {
|
|
72
|
+
res.writeHead(404);
|
|
73
|
+
res.end('Not Found');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await new Promise<void>((resolve, reject) => {
|
|
78
|
+
this._server!.listen(this._config.port, this._config.host, () => {
|
|
79
|
+
const addr = this._server!.address();
|
|
80
|
+
if (addr && typeof addr === 'object') {
|
|
81
|
+
this._serverUrl = `http://${addr.address === '::' ? 'localhost' : addr.address}:${addr.port}`;
|
|
82
|
+
this._ready = true;
|
|
83
|
+
resolve();
|
|
84
|
+
} else {
|
|
85
|
+
reject(new Error('Failed to get server address'));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
this._server!.on('error', reject);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
console.log(`✅ MiMo TTS 服务器已启动: ${this._serverUrl}`);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
console.error('❌ MiMo TTS 初始化失败:', err.message);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 生成语音并返回可播放的 URL
|
|
101
|
+
*/
|
|
102
|
+
async synthesize(text: string, options?: {
|
|
103
|
+
voice?: string;
|
|
104
|
+
style?: string;
|
|
105
|
+
}): Promise<{ url: string; success: boolean; error?: string; duration?: number }> {
|
|
106
|
+
if (!this._ready) {
|
|
107
|
+
const ok = await this.init();
|
|
108
|
+
if (!ok) return { url: '', success: false, error: 'TTS server not initialized' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// 构建请求
|
|
113
|
+
const messages: Array<{ role: string; content: string }> = [];
|
|
114
|
+
|
|
115
|
+
const style = options?.style ?? this._config.style;
|
|
116
|
+
if (style) {
|
|
117
|
+
messages.push({ role: 'user', content: style });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
messages.push({ role: 'assistant', content: text });
|
|
121
|
+
|
|
122
|
+
const voice = options?.voice ?? this._config.voice;
|
|
123
|
+
|
|
124
|
+
const useStream = this._config.stream;
|
|
125
|
+
|
|
126
|
+
const body = {
|
|
127
|
+
model: this._config.model,
|
|
128
|
+
messages,
|
|
129
|
+
audio: { format: useStream ? 'pcm16' : 'wav', voice },
|
|
130
|
+
stream: useStream,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// 调用 MiMo API
|
|
134
|
+
const response = await fetch(`${this._config.baseUrl}/chat/completions`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'api-key': this._config.apiKey,
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify(body),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const errText = await response.text().catch(() => 'unknown');
|
|
145
|
+
return { url: '', success: false, error: `MiMo API error ${response.status}: ${errText}` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let audioBuffer: Buffer;
|
|
149
|
+
|
|
150
|
+
if (useStream) {
|
|
151
|
+
// 流式模式:收集所有 PCM16 chunk 并拼接为 WAV
|
|
152
|
+
const pcmChunks: Buffer[] = [];
|
|
153
|
+
const reader = response.body?.getReader();
|
|
154
|
+
if (!reader) {
|
|
155
|
+
return { url: '', success: false, error: 'Failed to read streaming response' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const decoder = new TextDecoder();
|
|
159
|
+
let buffer = '';
|
|
160
|
+
|
|
161
|
+
while (true) {
|
|
162
|
+
const { done, value } = await reader.read();
|
|
163
|
+
if (done) break;
|
|
164
|
+
|
|
165
|
+
buffer += decoder.decode(value, { stream: true });
|
|
166
|
+
const lines = buffer.split('\n');
|
|
167
|
+
buffer = lines.pop() ?? '';
|
|
168
|
+
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
const trimmed = line.trim();
|
|
171
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
172
|
+
const jsonStr = trimmed.slice(6);
|
|
173
|
+
if (jsonStr === '[DONE]') continue;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const chunk = JSON.parse(jsonStr);
|
|
177
|
+
const audioData = chunk?.choices?.[0]?.delta?.audio?.data;
|
|
178
|
+
if (audioData) {
|
|
179
|
+
pcmChunks.push(Buffer.from(audioData, 'base64'));
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// 忽略解析错误
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (pcmChunks.length === 0) {
|
|
188
|
+
return { url: '', success: false, error: 'No audio data in streaming response' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// PCM16 → WAV(24kHz, mono, 16-bit)
|
|
192
|
+
const pcmData = Buffer.concat(pcmChunks);
|
|
193
|
+
audioBuffer = pcmToWav(pcmData, 24000, 1, 16);
|
|
194
|
+
} else {
|
|
195
|
+
// 非流式模式:直接从响应中获取 WAV
|
|
196
|
+
const data = await response.json() as any;
|
|
197
|
+
const audioBase64 = data?.choices?.[0]?.message?.audio?.data;
|
|
198
|
+
|
|
199
|
+
if (!audioBase64) {
|
|
200
|
+
return { url: '', success: false, error: 'No audio data in MiMo response' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
audioBuffer = Buffer.from(audioBase64, 'base64');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 保存音频文件
|
|
207
|
+
const fileName = `${randomUUID()}.wav`;
|
|
208
|
+
const filePath = join(this._audioDir, fileName);
|
|
209
|
+
await writeFile(filePath, audioBuffer);
|
|
210
|
+
|
|
211
|
+
const url = `${this._serverUrl}/audio/${fileName}`;
|
|
212
|
+
|
|
213
|
+
// 计算音频时长(秒)
|
|
214
|
+
// WAV: PCM 16-bit mono 24000 Hz
|
|
215
|
+
const headerSize = 44;
|
|
216
|
+
const sampleRate = 24000;
|
|
217
|
+
const bytesPerSample = 2;
|
|
218
|
+
const duration = (audioBuffer.length - headerSize) / (sampleRate * bytesPerSample);
|
|
219
|
+
|
|
220
|
+
return { url, success: true, duration };
|
|
221
|
+
} catch (err: any) {
|
|
222
|
+
return { url: '', success: false, error: err.message };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 清理临时文件和关闭服务器
|
|
228
|
+
*/
|
|
229
|
+
async destroy() {
|
|
230
|
+
if (this._server) {
|
|
231
|
+
await new Promise<void>((resolve) => {
|
|
232
|
+
this._server!.close(() => resolve());
|
|
233
|
+
});
|
|
234
|
+
this._server = null;
|
|
235
|
+
}
|
|
236
|
+
if (this._audioDir) {
|
|
237
|
+
await rm(this._audioDir, { recursive: true, force: true }).catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
this._ready = false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
get ready() {
|
|
243
|
+
return this._ready;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
get serverUrl() {
|
|
247
|
+
return this._serverUrl;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 将 WAV 文件作为 HTTP 响应返回
|
|
253
|
+
*/
|
|
254
|
+
async function serveWavFile(filePath: string, res: import('node:http').ServerResponse) {
|
|
255
|
+
const { readFile } = await import('node:fs/promises');
|
|
256
|
+
const data = await readFile(filePath);
|
|
257
|
+
res.writeHead(200, {
|
|
258
|
+
'Content-Type': 'audio/wav',
|
|
259
|
+
'Content-Length': data.length,
|
|
260
|
+
'Cache-Control': 'no-cache',
|
|
261
|
+
});
|
|
262
|
+
res.end(data);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* PCM16 原始数据 → WAV 文件 Buffer
|
|
267
|
+
* @param pcmData PCM16 原始音频数据
|
|
268
|
+
* @param sampleRate 采样率(MiMo TTS 流式输出为 24000)
|
|
269
|
+
* @param channels 声道数(1 = mono)
|
|
270
|
+
* @param bitsPerSample 每样本位数(16)
|
|
271
|
+
*/
|
|
272
|
+
function pcmToWav(pcmData: Buffer, sampleRate: number, channels: number, bitsPerSample: number): Buffer {
|
|
273
|
+
const byteRate = sampleRate * channels * (bitsPerSample / 8);
|
|
274
|
+
const blockAlign = channels * (bitsPerSample / 8);
|
|
275
|
+
const dataSize = pcmData.length;
|
|
276
|
+
const headerSize = 44;
|
|
277
|
+
const buffer = Buffer.alloc(headerSize + dataSize);
|
|
278
|
+
|
|
279
|
+
// RIFF header
|
|
280
|
+
buffer.write('RIFF', 0);
|
|
281
|
+
buffer.writeUInt32LE(36 + dataSize, 4);
|
|
282
|
+
buffer.write('WAVE', 8);
|
|
283
|
+
|
|
284
|
+
// fmt sub-chunk
|
|
285
|
+
buffer.write('fmt ', 12);
|
|
286
|
+
buffer.writeUInt32LE(16, 16); // sub-chunk size
|
|
287
|
+
buffer.writeUInt16LE(1, 20); // PCM format
|
|
288
|
+
buffer.writeUInt16LE(channels, 22);
|
|
289
|
+
buffer.writeUInt32LE(sampleRate, 24);
|
|
290
|
+
buffer.writeUInt32LE(byteRate, 28);
|
|
291
|
+
buffer.writeUInt16LE(blockAlign, 32);
|
|
292
|
+
buffer.writeUInt16LE(bitsPerSample, 34);
|
|
293
|
+
|
|
294
|
+
// data sub-chunk
|
|
295
|
+
buffer.write('data', 36);
|
|
296
|
+
buffer.writeUInt32LE(dataSize, 40);
|
|
297
|
+
pcmData.copy(buffer, headerSize);
|
|
298
|
+
|
|
299
|
+
return buffer;
|
|
300
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Re-export types from config.ts
|
|
2
|
+
export type {
|
|
3
|
+
MiGPTConfig,
|
|
4
|
+
MiGPTAccountConfig,
|
|
5
|
+
ResolvedMiAccount,
|
|
6
|
+
ExtendedOpenClawConfig,
|
|
7
|
+
} from './config.js';
|
|
8
|
+
|
|
9
|
+
// Message types
|
|
10
|
+
export interface IMessage {
|
|
11
|
+
id: string;
|
|
12
|
+
sender: 'user';
|
|
13
|
+
text: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
deviceId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Device types
|
|
19
|
+
export interface MiDevice {
|
|
20
|
+
did: string;
|
|
21
|
+
name: string;
|
|
22
|
+
model?: string;
|
|
23
|
+
mac?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Message event types
|
|
27
|
+
export interface MiMessageEvent {
|
|
28
|
+
channel: 'migpt';
|
|
29
|
+
accountId: string;
|
|
30
|
+
from: string;
|
|
31
|
+
fromName: string;
|
|
32
|
+
text: string;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { jsonDecode } from './parse.js';
|
|
2
|
+
import * as pako from 'pako';
|
|
3
|
+
import { randomNoise, signNonce } from './hash.js';
|
|
4
|
+
import { createHmac } from 'crypto';
|
|
5
|
+
import { Debugger } from './debug.js';
|
|
6
|
+
import type { MiPass } from '../mi/typing.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 解析登录响应中的认证参数
|
|
10
|
+
* 参考 migpt-next 实现
|
|
11
|
+
*/
|
|
12
|
+
export function parseAuthPass(res: string): Partial<{
|
|
13
|
+
code: number;
|
|
14
|
+
description: string;
|
|
15
|
+
captchaUrl: string;
|
|
16
|
+
notificationUrl: string;
|
|
17
|
+
} & MiPass> {
|
|
18
|
+
try {
|
|
19
|
+
// 如果传入的是 HttpResponse 对象,提取 data 字段
|
|
20
|
+
return (
|
|
21
|
+
jsonDecode(
|
|
22
|
+
res
|
|
23
|
+
.replace('&&&START&&&', '') // 去除前缀
|
|
24
|
+
.replace(/:(\d{9,})/g, ':"$1"'), // 把 userId 和 nonce 转成 string
|
|
25
|
+
) ?? {}
|
|
26
|
+
);
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function encodeQuery(data: Record<string, string | number | boolean | undefined>): string {
|
|
33
|
+
return Object.entries(data)
|
|
34
|
+
.map(
|
|
35
|
+
([key, value]) =>
|
|
36
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value == null ? '' : value.toString())}`,
|
|
37
|
+
)
|
|
38
|
+
.join('&');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function decodeQuery(str: string) {
|
|
42
|
+
const data: any = {};
|
|
43
|
+
if (!str) {
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
const ss: any = str.split('&');
|
|
47
|
+
for (let i = 0; i < ss.length; i++) {
|
|
48
|
+
const s = ss[i].split('=');
|
|
49
|
+
if (s.length != 2) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const k = decodeURIComponent(s[0]);
|
|
53
|
+
let v: any = decodeURIComponent(s[1]);
|
|
54
|
+
if (/^\[{/.test(v)) {
|
|
55
|
+
try {
|
|
56
|
+
v = jsonDecode(v);
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
data[k] = v;
|
|
62
|
+
}
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* URL 编码对象为 form 格式
|
|
68
|
+
*/
|
|
69
|
+
export function encodeFormData(data: Record<string, any>): string {
|
|
70
|
+
return Object.entries(data)
|
|
71
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
72
|
+
.join('&');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface MIoTRequest {
|
|
76
|
+
data: string;
|
|
77
|
+
signature: string;
|
|
78
|
+
_nonce: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// RC4 实现(用于兼容旧版本和响应解密)
|
|
82
|
+
class RC4 {
|
|
83
|
+
private i: number = 0;
|
|
84
|
+
private j: number = 0;
|
|
85
|
+
private S: Buffer;
|
|
86
|
+
|
|
87
|
+
constructor(key: Buffer) {
|
|
88
|
+
this.S = Buffer.alloc(256);
|
|
89
|
+
for (let idx = 0; idx < 256; idx++) {
|
|
90
|
+
this.S[idx] = idx;
|
|
91
|
+
}
|
|
92
|
+
let j = 0;
|
|
93
|
+
for (let idx = 0; idx < 256; idx++) {
|
|
94
|
+
j = (j + this.S[idx] + key[idx % key.length]) & 0xff;
|
|
95
|
+
[this.S[idx], this.S[j]] = [this.S[j], this.S[idx]];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
update(data: Buffer): Buffer {
|
|
100
|
+
const result = Buffer.alloc(data.length);
|
|
101
|
+
for (let n = 0; n < data.length; n++) {
|
|
102
|
+
this.i = (this.i + 1) & 0xff;
|
|
103
|
+
this.j = (this.j + this.S[this.i]) & 0xff;
|
|
104
|
+
[this.S[this.i], this.S[this.j]] = [this.S[this.j], this.S[this.i]];
|
|
105
|
+
const K = this.S[(this.S[this.i] + this.S[this.j]) & 0xff];
|
|
106
|
+
result[n] = data[n] ^ K;
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* MIoT 签名算法 - 参考 MiService Python 实现
|
|
114
|
+
* 签名格式:uri&snonce&nonce&data=xxx
|
|
115
|
+
* 使用 HMAC-SHA256
|
|
116
|
+
* 注意:只签名,不加密数据!
|
|
117
|
+
*/
|
|
118
|
+
function signMIoT(uri: string, snonce: string, nonce: string, data: string): string {
|
|
119
|
+
const msg = `${uri}&${snonce}&${nonce}&data=${data}`;
|
|
120
|
+
const key = Buffer.from(snonce, 'base64');
|
|
121
|
+
return createHmac('sha256', key).update(msg).digest('base64');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* MIoT 请求编码 - 参考 MiService sign_data
|
|
126
|
+
* 只签名,不加密数据
|
|
127
|
+
*/
|
|
128
|
+
export function encodeMIoT(uri: string, data: any, ssecurity: string): MIoTRequest {
|
|
129
|
+
const nonce = randomNoise();
|
|
130
|
+
const snonce = signNonce(ssecurity, nonce) as string;
|
|
131
|
+
// Python 的 json.dumps 默认格式:": " 和 ", "
|
|
132
|
+
// JavaScript 的 JSON.stringify 默认紧凑格式,需要手动替换
|
|
133
|
+
const json = JSON.stringify(data).replace(/:/g, ': ').replace(/,/g, ', ');
|
|
134
|
+
|
|
135
|
+
// HMAC-SHA256 签名
|
|
136
|
+
const signature = signMIoT(uri, snonce, nonce, json);
|
|
137
|
+
|
|
138
|
+
if (Debugger.debug) {
|
|
139
|
+
console.log('encodeMIoT 签名详情:', {
|
|
140
|
+
uri,
|
|
141
|
+
ssecurity: ssecurity.slice(0, 20) + '...',
|
|
142
|
+
nonce,
|
|
143
|
+
snonce,
|
|
144
|
+
json,
|
|
145
|
+
signature,
|
|
146
|
+
signMsg: `${uri}&${snonce}&${nonce}&data=${json}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
_nonce: nonce,
|
|
152
|
+
data: json,
|
|
153
|
+
signature: signature,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function decodeMIoT(
|
|
158
|
+
ssecurity: string,
|
|
159
|
+
nonce: string,
|
|
160
|
+
data: string,
|
|
161
|
+
gzip?: boolean,
|
|
162
|
+
): Promise<any | undefined> {
|
|
163
|
+
// 先尝试直接解析 JSON(MiService 响应是明文)
|
|
164
|
+
try {
|
|
165
|
+
const res = jsonDecode(data);
|
|
166
|
+
if (res) {
|
|
167
|
+
return Promise.resolve(res);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// ignore
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 如果直接解析失败,尝试 RC4 解密(兼容旧版本)
|
|
174
|
+
let decrypted: Buffer;
|
|
175
|
+
try {
|
|
176
|
+
const key = Buffer.from(signNonce(ssecurity, nonce), 'base64');
|
|
177
|
+
const rc4 = new RC4(key);
|
|
178
|
+
rc4.update(Buffer.alloc(1024));
|
|
179
|
+
decrypted = Buffer.from(rc4.update(Buffer.from(data, 'base64')));
|
|
180
|
+
// 如果 RC4 解密成功,尝试 gzip 解压
|
|
181
|
+
if (gzip) {
|
|
182
|
+
try {
|
|
183
|
+
decrypted = Buffer.from(pako.ungzip(decrypted));
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// ignore gzip error
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// 如果不是 RC4 加密的数据,直接使用原始数据
|
|
190
|
+
decrypted = Buffer.from(data, 'base64');
|
|
191
|
+
// 如果需要 gzip 解压
|
|
192
|
+
if (gzip) {
|
|
193
|
+
try {
|
|
194
|
+
decrypted = Buffer.from(pako.ungzip(decrypted));
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// ignore gzip error
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const res = jsonDecode(decrypted.toString());
|
|
202
|
+
if (!res) {
|
|
203
|
+
console.error('❌ decodeMIoT failed');
|
|
204
|
+
}
|
|
205
|
+
return Promise.resolve(res);
|
|
206
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 调试工具
|
|
3
|
+
*/
|
|
4
|
+
export class Debugger {
|
|
5
|
+
static debug = false;
|
|
6
|
+
|
|
7
|
+
static log(...args: any[]) {
|
|
8
|
+
if (this.debug) {
|
|
9
|
+
console.log('[MiGPT]', ...args);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static error(...args: any[]) {
|
|
14
|
+
console.error('[MiGPT]', ...args);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MD5 哈希
|
|
5
|
+
*/
|
|
6
|
+
export function md5(data: string | Buffer): string {
|
|
7
|
+
return crypto.createHash('md5').update(data).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* SHA1 哈希
|
|
12
|
+
*/
|
|
13
|
+
export function sha1(data: string | Buffer): string {
|
|
14
|
+
return crypto.createHash('sha1').update(data).digest('base64');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SHA256 HMAC
|
|
19
|
+
*/
|
|
20
|
+
export function sha256(snonce: string, msg: string): string {
|
|
21
|
+
return crypto.createHmac('sha256', Buffer.from(snonce, 'base64')).update(msg).digest('base64');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 签名 nonce(用于 MIoT)
|
|
26
|
+
* 参考 migpt-next 实现
|
|
27
|
+
*/
|
|
28
|
+
export function signNonce(ssecurity: string, nonce: string): string {
|
|
29
|
+
const m = crypto.createHash('sha256');
|
|
30
|
+
m.update(ssecurity, 'base64');
|
|
31
|
+
m.update(nonce, 'base64');
|
|
32
|
+
return m.digest().toString('base64');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 生成 UUID
|
|
37
|
+
*/
|
|
38
|
+
export function uuid(): string {
|
|
39
|
+
return crypto.randomUUID();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 生成随机字符串
|
|
44
|
+
*/
|
|
45
|
+
export function randomString(len: number): string {
|
|
46
|
+
if (len < 1) return '';
|
|
47
|
+
const s = Math.random().toString(36).slice(2);
|
|
48
|
+
return s + randomString(len - s.length);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 生成随机噪声(用于 MIoT nonce)
|
|
53
|
+
* 参考 MiService Python 实现:urandom(8) + int(time.time() / 60).to_bytes(4, "big")
|
|
54
|
+
*/
|
|
55
|
+
export function randomNoise(): string {
|
|
56
|
+
const randomBytes = crypto.randomBytes(8);
|
|
57
|
+
const timeInMinutes = Math.floor(Date.now() / 1000 / 60);
|
|
58
|
+
// 使用 Uint32BE 写入 4 字节时间戳(大端序)
|
|
59
|
+
const timeBuffer = Buffer.alloc(4);
|
|
60
|
+
timeBuffer.writeUInt32BE(timeInMinutes >>> 0, 0);
|
|
61
|
+
return Buffer.concat([randomBytes, timeBuffer]).toString('base64');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* RC4 加密
|
|
66
|
+
*/
|
|
67
|
+
export function rc4Encrypt(key: Buffer, data: Buffer): Buffer {
|
|
68
|
+
const ksa = (key: Buffer): number[] => {
|
|
69
|
+
const S = new Array(256);
|
|
70
|
+
for (let i = 0; i < 256; i++) {
|
|
71
|
+
S[i] = i;
|
|
72
|
+
}
|
|
73
|
+
let j = 0;
|
|
74
|
+
for (let i = 0; i < 256; i++) {
|
|
75
|
+
j = (j + S[i] + key[i % key.length]) & 0xff;
|
|
76
|
+
[S[i], S[j]] = [S[j], S[i]];
|
|
77
|
+
}
|
|
78
|
+
return S;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const prga = (S: number[], data: Buffer): Buffer => {
|
|
82
|
+
const result = Buffer.alloc(data.length);
|
|
83
|
+
let i = 0;
|
|
84
|
+
let j = 0;
|
|
85
|
+
for (let n = 0; n < data.length; n++) {
|
|
86
|
+
i = (i + 1) & 0xff;
|
|
87
|
+
j = (j + S[i]) & 0xff;
|
|
88
|
+
[S[i], S[j]] = [S[j], S[i]];
|
|
89
|
+
const K = S[(S[i] + S[j]) & 0xff];
|
|
90
|
+
result[n] = data[n] ^ K;
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const S = ksa(key);
|
|
96
|
+
return prga(S, data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* RC4 解密(与加密相同)
|
|
101
|
+
*/
|
|
102
|
+
export function rc4Decrypt(key: Buffer, data: Buffer): Buffer {
|
|
103
|
+
return rc4Encrypt(key, data);
|
|
104
|
+
}
|