@aluria/wechat-ai-api 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 +7 -0
- package/dist/index.d.ts +401 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/readme.md +167 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Aluria
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
interface WeChatClientConfig {
|
|
4
|
+
/** 你的应用 ID (对应原 config.json 的 ilink_appid) */
|
|
5
|
+
appId: string;
|
|
6
|
+
/** 客户端版本号字符串,如 "1.0.11" */
|
|
7
|
+
version: string;
|
|
8
|
+
/** 基础 API 地址,通常为 https://ilinkai.weixin.qq.com */
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
/** 默认机器人类型, 为 "3" */
|
|
11
|
+
botType: string;
|
|
12
|
+
/** 机器人的登录凭证,登录前可为空 */
|
|
13
|
+
token?: string;
|
|
14
|
+
/** 收到图片/文件/视频时,是否在后台自动下载解密到内存中?默认 true */
|
|
15
|
+
autoDownloadMedia: boolean;
|
|
16
|
+
/** 用户 ID */
|
|
17
|
+
userId?: string;
|
|
18
|
+
}
|
|
19
|
+
interface CdnManagerConfig {
|
|
20
|
+
maxRetry: number;
|
|
21
|
+
cdnBaseUrl: string;
|
|
22
|
+
apiTimeoutMs: number;
|
|
23
|
+
backoffBaseMs: number;
|
|
24
|
+
}
|
|
25
|
+
interface SendMessageOptions {
|
|
26
|
+
/** * 上下文 Token。用于在特定的会话上下文中回复消息
|
|
27
|
+
*/
|
|
28
|
+
contextToken?: string;
|
|
29
|
+
/** * 接收消息的用户 ID (微信 ID),目前仅对自身账号有效,如果不提供,SDK 会尝试使用登录用户的 ID 作为默认值
|
|
30
|
+
*/
|
|
31
|
+
userId?: string;
|
|
32
|
+
/** * 媒体附件的文字说明。
|
|
33
|
+
* 注意:微信不支持图文混合在同一个 Item 中,
|
|
34
|
+
* 如果提供此参数,SDK 会先发送一条文本消息,紧接着发送媒体消息。
|
|
35
|
+
*/
|
|
36
|
+
caption?: string;
|
|
37
|
+
}
|
|
38
|
+
/** 核心引擎接收的请求参数 */
|
|
39
|
+
interface CoreRequestOptions {
|
|
40
|
+
method: "GET" | "POST";
|
|
41
|
+
/** 纯业务请求体,无需包含 base_info */
|
|
42
|
+
body?: Record<string, any>;
|
|
43
|
+
/** 超时时间 (毫秒) */
|
|
44
|
+
timeoutMs: number;
|
|
45
|
+
}
|
|
46
|
+
interface QrCodeInfo {
|
|
47
|
+
qrcodeId: string;
|
|
48
|
+
qrcodeUrl: string;
|
|
49
|
+
}
|
|
50
|
+
interface LoginCredentials {
|
|
51
|
+
token: string;
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
accountId: string;
|
|
54
|
+
userId: string;
|
|
55
|
+
}
|
|
56
|
+
type LogStatusChangePayload = {
|
|
57
|
+
status: LoginStatus.WAIT | LoginStatus.SCANNED | LoginStatus.EXPIRED;
|
|
58
|
+
message: string;
|
|
59
|
+
} | {
|
|
60
|
+
status: LoginStatus.REDIRECT;
|
|
61
|
+
message: string;
|
|
62
|
+
redirect_host: string;
|
|
63
|
+
} | {
|
|
64
|
+
status: LoginStatus.CONFIRMED;
|
|
65
|
+
message: string;
|
|
66
|
+
bot_token: string;
|
|
67
|
+
baseurl?: string;
|
|
68
|
+
ilink_bot_id: string;
|
|
69
|
+
ilink_user_id: string;
|
|
70
|
+
};
|
|
71
|
+
interface LoginOptions {
|
|
72
|
+
/** 当获取到新二维码时触发 (如果二维码过期会自动刷新并再次触发) */
|
|
73
|
+
onQrCode: (qrInfo: QrCodeInfo) => void;
|
|
74
|
+
/** 当轮询状态改变时触发 (可选) */
|
|
75
|
+
onStatusChange: (payload: LogStatusChangePayload) => void;
|
|
76
|
+
/** 二维码过期后最大重试次数,默认 3 */
|
|
77
|
+
maxRetries: number;
|
|
78
|
+
/** 单次长轮询的超时时间,默认 35000ms */
|
|
79
|
+
pollTimeoutMs: number;
|
|
80
|
+
}
|
|
81
|
+
interface SendResult {
|
|
82
|
+
clientId: string;
|
|
83
|
+
response: any;
|
|
84
|
+
}
|
|
85
|
+
/** 内部流转的 CDN 文件凭据 (CdnManager 产出,MessageManager 消费) */
|
|
86
|
+
interface CdnFileTicket {
|
|
87
|
+
filekey: string;
|
|
88
|
+
aeskeyHex: string;
|
|
89
|
+
aeskeyBuffer: Buffer;
|
|
90
|
+
fileSizePlain: number;
|
|
91
|
+
fileSizeCipher: number;
|
|
92
|
+
encryptedQueryParam: string;
|
|
93
|
+
}
|
|
94
|
+
/** 统一的 CDN 下载票据 (抹平了图片、文件、视频的差异) */
|
|
95
|
+
interface CdnDownloadTicket {
|
|
96
|
+
fullUrl?: string;
|
|
97
|
+
encryptedQueryParam?: string;
|
|
98
|
+
aesKeyBase64?: string;
|
|
99
|
+
isPlain: boolean;
|
|
100
|
+
originalFileName?: string;
|
|
101
|
+
}
|
|
102
|
+
type ItemTypeStr = "text" | "image" | "video" | "file" | "voice" | "unknown";
|
|
103
|
+
interface WeChatIncomingMessage<T extends RawMessageItemBase = RawMessageItem> {
|
|
104
|
+
messageId: string;
|
|
105
|
+
seq: number;
|
|
106
|
+
fromUserId: string;
|
|
107
|
+
toUserId: string;
|
|
108
|
+
timestamp: number;
|
|
109
|
+
contextToken: string;
|
|
110
|
+
msgType: T["type"];
|
|
111
|
+
msgTypeStr: ItemTypeStr;
|
|
112
|
+
/** 纯文本内容 */
|
|
113
|
+
text?: string;
|
|
114
|
+
/** 文件名 */
|
|
115
|
+
fileName?: string;
|
|
116
|
+
/**
|
|
117
|
+
* 获取媒体文件的二进制 Buffer。
|
|
118
|
+
* 如果开启了 autoDownloadMedia,此方法瞬间返回内存中的 Buffer。
|
|
119
|
+
* 如果关闭了,此方法会发起网络请求下载并解密,然后缓存。
|
|
120
|
+
*/
|
|
121
|
+
getBuffer?: () => Promise<Buffer | null>;
|
|
122
|
+
/** 获取语音消息的二进制 Buffer, 这是原始的 SILK 编码 */
|
|
123
|
+
getVoiceBuffer?: () => Promise<Buffer | null>;
|
|
124
|
+
/**
|
|
125
|
+
* 快捷方法:将媒体文件保存到本地磁盘。
|
|
126
|
+
* @param savePath 指定绝对或相对路径
|
|
127
|
+
*/
|
|
128
|
+
saveToFile?: (savePath: string) => Promise<string>;
|
|
129
|
+
/** 原始底层票据 */
|
|
130
|
+
mediaTicket?: CdnDownloadTicket;
|
|
131
|
+
/** 消息在同一 seq 中的索引,方便开发者处理多 Item 的情况 */
|
|
132
|
+
index: number;
|
|
133
|
+
/** 原始消息载荷,给高级玩家使用 */
|
|
134
|
+
raw: RawMessage<T>;
|
|
135
|
+
}
|
|
136
|
+
declare const enum MessageType {
|
|
137
|
+
NONE = 0,
|
|
138
|
+
USER = 1,
|
|
139
|
+
BOT = 2
|
|
140
|
+
}
|
|
141
|
+
declare const enum MessageItemType {
|
|
142
|
+
NONE = 0,
|
|
143
|
+
TEXT = 1,
|
|
144
|
+
IMAGE = 2,
|
|
145
|
+
VOICE = 3,
|
|
146
|
+
FILE = 4,
|
|
147
|
+
VIDEO = 5
|
|
148
|
+
}
|
|
149
|
+
declare const enum MessageState {
|
|
150
|
+
NEW = 0,
|
|
151
|
+
GENERATING = 1,
|
|
152
|
+
FINISH = 2
|
|
153
|
+
}
|
|
154
|
+
declare const enum UploadMediaType {
|
|
155
|
+
IMAGE = 1,
|
|
156
|
+
VIDEO = 2,
|
|
157
|
+
FILE = 3,
|
|
158
|
+
VOICE = 4
|
|
159
|
+
}
|
|
160
|
+
interface RawMessage<T extends RawMessageItemBase = RawMessageItem> {
|
|
161
|
+
seq: number;
|
|
162
|
+
message_id: number;
|
|
163
|
+
from_user_id: string;
|
|
164
|
+
to_user_id: string;
|
|
165
|
+
client_id: string;
|
|
166
|
+
create_time_ms: number;
|
|
167
|
+
update_time_ms: number;
|
|
168
|
+
delete_time_ms: number | 0;
|
|
169
|
+
session_id: string | "";
|
|
170
|
+
group_id: string | "";
|
|
171
|
+
message_type: MessageType;
|
|
172
|
+
message_state: MessageState;
|
|
173
|
+
item_list: T[];
|
|
174
|
+
context_token: string;
|
|
175
|
+
}
|
|
176
|
+
type RawMessageItem = RawMessageTextItem | RawMessageFileItem | RawMessageImageItem | RawMessageVideoItem | RawMessageVoiceItem;
|
|
177
|
+
interface RawMessageItemBase {
|
|
178
|
+
type: MessageItemType;
|
|
179
|
+
create_time_ms: number;
|
|
180
|
+
update_time_ms: number;
|
|
181
|
+
is_completed: boolean;
|
|
182
|
+
}
|
|
183
|
+
interface RawMessageTextItem extends RawMessageItemBase {
|
|
184
|
+
type: MessageItemType.TEXT;
|
|
185
|
+
text_item: {
|
|
186
|
+
text: string;
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
interface RawMessageFileItem extends RawMessageItemBase {
|
|
190
|
+
type: MessageItemType.FILE;
|
|
191
|
+
file_item: {
|
|
192
|
+
media: RawMessageMedia;
|
|
193
|
+
file_name: string;
|
|
194
|
+
md5: string;
|
|
195
|
+
len: string;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
interface RawMessageImageItem extends RawMessageItemBase {
|
|
199
|
+
type: MessageItemType.IMAGE;
|
|
200
|
+
image_item: {
|
|
201
|
+
url: string;
|
|
202
|
+
aeskey: string;
|
|
203
|
+
media: RawMessageMedia;
|
|
204
|
+
mid_size: number;
|
|
205
|
+
thumb_size: number;
|
|
206
|
+
thumb_height: number;
|
|
207
|
+
thumb_width: number;
|
|
208
|
+
hd_size: number;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
interface RawMessageVideoItem extends RawMessageItemBase {
|
|
212
|
+
type: MessageItemType.VIDEO;
|
|
213
|
+
video_item: {
|
|
214
|
+
media: RawMessageMedia;
|
|
215
|
+
video_size: number;
|
|
216
|
+
play_length: number;
|
|
217
|
+
video_md5: string;
|
|
218
|
+
thumb_media: RawMessageMedia;
|
|
219
|
+
thumb_size: number;
|
|
220
|
+
thumb_height: number;
|
|
221
|
+
thumb_width: number;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
interface RawMessageVoiceItem extends RawMessageItemBase {
|
|
225
|
+
type: MessageItemType.VOICE;
|
|
226
|
+
voice_item: {
|
|
227
|
+
media: RawMessageMedia;
|
|
228
|
+
encode_type: number | 4;
|
|
229
|
+
bits_per_sample: number | 16;
|
|
230
|
+
sample_rate: number | 16000;
|
|
231
|
+
playtime: number;
|
|
232
|
+
text: string | "";
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
interface RawMessageMedia {
|
|
236
|
+
encrypt_query_param: string;
|
|
237
|
+
aes_key: string;
|
|
238
|
+
full_url: string;
|
|
239
|
+
}
|
|
240
|
+
interface WeChatApiEventMap {
|
|
241
|
+
login: [credentials: LoginCredentials];
|
|
242
|
+
message: [msg: WeChatIncomingMessage];
|
|
243
|
+
text: [msg: WeChatIncomingMessage<RawMessageTextItem>];
|
|
244
|
+
file: [msg: WeChatIncomingMessage<RawMessageFileItem>];
|
|
245
|
+
image: [msg: WeChatIncomingMessage<RawMessageImageItem>];
|
|
246
|
+
video: [msg: WeChatIncomingMessage<RawMessageVideoItem>];
|
|
247
|
+
voice: [msg: WeChatIncomingMessage<RawMessageVoiceItem>];
|
|
248
|
+
error: [error: Error];
|
|
249
|
+
}
|
|
250
|
+
declare const enum LoginStatus {
|
|
251
|
+
WAIT = "wait",
|
|
252
|
+
SCANNED = "scaned",// 保持和微信接口错别字一致
|
|
253
|
+
REDIRECT = "scaned_but_redirect",
|
|
254
|
+
CONFIRMED = "confirmed",
|
|
255
|
+
EXPIRED = "expired"
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
declare class WeChatCore {
|
|
259
|
+
private config;
|
|
260
|
+
private clientVersionInt;
|
|
261
|
+
constructor(config: WeChatClientConfig);
|
|
262
|
+
/** 设置或更新登录凭证 */
|
|
263
|
+
setToken(token: string): void;
|
|
264
|
+
/** 更新基础网关 URL (用于处理 IDC 重定向) */
|
|
265
|
+
setBaseUrl(baseUrl: string): void;
|
|
266
|
+
setUserId(userId: string): void;
|
|
267
|
+
/** 获取当前的 BaseUrl (供某些需要拼接绝对路径的特殊场景使用) */
|
|
268
|
+
getBaseUrl(): string;
|
|
269
|
+
/** 获取当前的机器人类型 */
|
|
270
|
+
getBotType(): string;
|
|
271
|
+
/** 获取当前的用户 ID */
|
|
272
|
+
getUserId(): string | undefined;
|
|
273
|
+
/**
|
|
274
|
+
* 统一的 HTTP 请求方法
|
|
275
|
+
* @param endpoint 接口路径,例如 "ilink/bot/sendmessage"
|
|
276
|
+
* @param options 请求配置 (方法、请求体、超时设置)
|
|
277
|
+
*/
|
|
278
|
+
request<T>(endpoint: string, options: CoreRequestOptions): Promise<T>;
|
|
279
|
+
/** 构造标准请求头 */
|
|
280
|
+
private buildHeaders;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
declare class AuthManager {
|
|
284
|
+
private core;
|
|
285
|
+
constructor(core: WeChatCore);
|
|
286
|
+
getQrCode(): Promise<QrCodeInfo>;
|
|
287
|
+
pollLoginStatus(qrcodeId: string, timeoutMs?: number): Promise<{
|
|
288
|
+
status: LoginStatus;
|
|
289
|
+
data?: any;
|
|
290
|
+
}>;
|
|
291
|
+
login(options: LoginOptions): Promise<LoginCredentials>;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
declare class CdnManager {
|
|
295
|
+
private core;
|
|
296
|
+
private config;
|
|
297
|
+
constructor(core: WeChatCore, config?: Partial<CdnManagerConfig>);
|
|
298
|
+
/**
|
|
299
|
+
* 将任意 Buffer 上传至微信 CDN,并返回发送消息所需的凭证
|
|
300
|
+
* @param buffer 文件的二进制原始数据
|
|
301
|
+
* @param toUserId 接收人的微信 ID (CDN 强制要求绑定)
|
|
302
|
+
* @param mediaType 媒体类型 (IMAGE, VIDEO, FILE, VOICE)
|
|
303
|
+
*/
|
|
304
|
+
uploadBuffer(buffer: Buffer, toUserId: string, mediaType: UploadMediaType): Promise<CdnFileTicket>;
|
|
305
|
+
/**
|
|
306
|
+
* 将密文 POST 到 CDN,并从 Response Header 中抠出下载参数。
|
|
307
|
+
* 包含 4xx 直接报错、5xx 重试的逻辑。
|
|
308
|
+
*/
|
|
309
|
+
private postToCdn;
|
|
310
|
+
/**
|
|
311
|
+
* 唯一对内暴露的下载引擎
|
|
312
|
+
* 负责拉取字节流并根据协议规范进行解密
|
|
313
|
+
*/
|
|
314
|
+
downloadBuffer(ticket: CdnDownloadTicket): Promise<Buffer>;
|
|
315
|
+
/**
|
|
316
|
+
* 处理微信极其不一致的密钥编码:
|
|
317
|
+
* 情况A: base64( raw 16 bytes )
|
|
318
|
+
* 情况B: base64( hex string of 16 bytes )
|
|
319
|
+
*/
|
|
320
|
+
private parseWechatAesKey;
|
|
321
|
+
/**
|
|
322
|
+
* 原生 fetch 拉取二进制字节流
|
|
323
|
+
*/
|
|
324
|
+
private fetchCdnBytes;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
declare class MessageManager {
|
|
328
|
+
private core;
|
|
329
|
+
private cdn;
|
|
330
|
+
constructor(core: WeChatCore, cdn: CdnManager);
|
|
331
|
+
sendText(text: string, options?: Partial<SendMessageOptions>): Promise<SendResult>;
|
|
332
|
+
sendImage(filePath: string, options?: Partial<SendMessageOptions>): Promise<SendResult>;
|
|
333
|
+
sendVideo(filePath: string, options?: Partial<SendMessageOptions>): Promise<SendResult>;
|
|
334
|
+
/** - 仅允许发送文件, 发送**图片或视频**会导致发送失败!
|
|
335
|
+
* - 发送图片和视频应使用 `sendImage` 或 `sendVideo` */
|
|
336
|
+
sendFile(filePath: string, options?: Partial<SendMessageOptions>): Promise<SendResult>;
|
|
337
|
+
/**
|
|
338
|
+
* 统一的媒体发送工作流:读取本地文件 -> 上传 CDN -> (可选发送 Caption) -> 发送媒体消息
|
|
339
|
+
*/
|
|
340
|
+
private _sendMediaWorkflow;
|
|
341
|
+
/**
|
|
342
|
+
* 最底层的发包函数,将组装好的 Item 包装成完整的微信请求体并 POST
|
|
343
|
+
*/
|
|
344
|
+
private _sendRawItem;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
declare class MessageParser {
|
|
348
|
+
private cdn;
|
|
349
|
+
private config;
|
|
350
|
+
constructor(cdn: CdnManager, config: WeChatClientConfig);
|
|
351
|
+
/**
|
|
352
|
+
* 将微信底层的复杂 JSON 扁平化为开发者友好的对象
|
|
353
|
+
*/
|
|
354
|
+
parse(raw: RawMessage): Promise<WeChatIncomingMessage[] | null>;
|
|
355
|
+
/**
|
|
356
|
+
* 从原始 JSON 中提取标准化的 CDN 下载票据
|
|
357
|
+
*/
|
|
358
|
+
private _extractDownloadTicket;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
declare class WeChatApi extends EventEmitter<WeChatApiEventMap> {
|
|
362
|
+
readonly core: WeChatCore;
|
|
363
|
+
readonly auth: AuthManager;
|
|
364
|
+
readonly messages: MessageManager;
|
|
365
|
+
readonly parser: MessageParser;
|
|
366
|
+
readonly cdn: CdnManager;
|
|
367
|
+
private currentCredentials;
|
|
368
|
+
private isPolling;
|
|
369
|
+
private syncBuf;
|
|
370
|
+
constructor(config?: Partial<WeChatClientConfig>);
|
|
371
|
+
/**
|
|
372
|
+
* 启动扫码登录流程。
|
|
373
|
+
* @param options 可选。如果为空,将使用默认的控制台交互体验。
|
|
374
|
+
*/
|
|
375
|
+
login(options?: Partial<LoginOptions>): Promise<LoginCredentials>;
|
|
376
|
+
/**
|
|
377
|
+
* 导出当前的登录凭证。
|
|
378
|
+
* 开发者拿到后可以自行保存到本地文件或数据库中。
|
|
379
|
+
*/
|
|
380
|
+
exportCredentials(): LoginCredentials | null;
|
|
381
|
+
/**
|
|
382
|
+
* 加载现有的登录凭证。
|
|
383
|
+
* 适用于程序重启后,免扫码直接恢复状态。
|
|
384
|
+
*/
|
|
385
|
+
loadCredentials(credentials: LoginCredentials): void;
|
|
386
|
+
/**
|
|
387
|
+
* 验证当前加载的凭证是否仍然有效。
|
|
388
|
+
* @returns boolean 是否有效
|
|
389
|
+
*/
|
|
390
|
+
verifyCredentials(): Promise<boolean>;
|
|
391
|
+
/**
|
|
392
|
+
* 启动长轮询,监听新消息
|
|
393
|
+
*/
|
|
394
|
+
startPolling(): Promise<void>;
|
|
395
|
+
/**
|
|
396
|
+
* 停止长轮询
|
|
397
|
+
*/
|
|
398
|
+
stopPolling(): void;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export { type LoginCredentials, type LoginOptions, LoginStatus, type QrCodeInfo, type SendMessageOptions, WeChatApi, type WeChatClientConfig, type WeChatIncomingMessage };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import{EventEmitter as re}from"events";import{createCipheriv as q,createDecipheriv as H,createHash as V,randomBytes as O}from"crypto";var $="https://ilinkai.weixin.qq.com",G="https://cdn.weixin.qq.com";var b="aes-128-ecb";var g={AUTH_TYPE:"AuthorizationType",UIN:"X-WECHAT-UIN",APP_ID:"iLink-App-Id",CLIENT_VERSION:"iLink-App-ClientVersion",CONTENT_TYPE:"Content-Type",AUTHORIZATION:"Authorization",ERROR_MSG:"x-error-message",ENCRYPTED_PARAM:"x-encrypted-param"},w={AUTH_TYPE_VALUE:"ilink_bot_token",CONTENT_TYPE_JSON:"application/json",DEFAULT_TIMEOUT_MS:3e4},_={appId:"bot",version:"2.1.1",baseUrl:$,botType:"3",autoDownloadMedia:!0},M={maxRetry:3,cdnBaseUrl:G,apiTimeoutMs:15e3,backoffBaseMs:1e3},R={maxRetries:3,pollTimeoutMs:35e3,onQrCode:a=>{console.log(`
|
|
2
|
+
==========================================`),console.log("\u8BF7\u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00\u4EE5\u4E0B\u94FE\u63A5\uFF0C\u5E76\u4F7F\u7528\u5FAE\u4FE1\u626B\u7801\uFF1A"),console.log(a.qrcodeUrl),console.log(`==========================================
|
|
3
|
+
`)},onStatusChange:a=>{switch(a.status){case"wait":console.log("\u7B49\u5F85\u767B\u5F55...");break;case"scaned":console.log(`
|
|
4
|
+
\u{1F440} \u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u5FAE\u4FE1\u4E0A\u70B9\u51FB\u786E\u8BA4\u767B\u5F55...`);break;case"confirmed":console.log(`
|
|
5
|
+
\u5DF2\u94FE\u63A5, \u4F46\u9700\u624B\u52A8\u5728\u5FAE\u4FE1\u53D1\u9001\u7B2C\u4E00\u6761\u6D88\u606F\u540E, bot \u624D\u80FD\u6B63\u5E38\u56DE\u590D`);break;case"scaned_but_redirect":console.log(`
|
|
6
|
+
[\u7CFB\u7EDF] ${a.message}`);break}}},f={contextToken:void 0,userId:void 0,caption:void 0};var d=class{static md5(e){return V("md5").update(e).digest("hex")}static generateRandomKey(e=16){return O(e)}static getPaddedSize(e){return Math.ceil((e+1)/16)*16}static aesEcbEncrypt(e,t){if(t.length!==16)throw new Error(`[CryptoUtils] AES-128 \u5BC6\u94A5\u957F\u5EA6\u5FC5\u987B\u4E3A ${16} \u5B57\u8282\uFF0C\u5F53\u524D\u4E3A ${t.length} \u5B57\u8282`);let r=q(b,t,null);return Buffer.concat([r.update(e),r.final()])}static aesEcbDecrypt(e,t){if(t.length!==16)throw new Error(`[CryptoUtils] AES-128 \u5BC6\u94A5\u957F\u5EA6\u5FC5\u987B\u4E3A ${16} \u5B57\u8282\uFF0C\u5F53\u524D\u4E3A ${t.length} \u5B57\u8282`);let r=H(b,t,null);return Buffer.concat([r.update(e),r.final()])}static generateClientId(){let e=O(4).toString("hex");return`wechat-bot:${Date.now()}-${e}`}static randomWechatUin(){let e=O(4).readUInt32BE(0);return Buffer.from(String(e),"utf-8").toString("base64")}};var S=(s=>(s.WAIT="wait",s.SCANNED="scaned",s.REDIRECT="scaned_but_redirect",s.CONFIRMED="confirmed",s.EXPIRED="expired",s))(S||{});function m(a,...e){let t={...a};for(let r of e)for(let n in r)r[n]!==void 0&&(t[n]=r[n]);return t}function L(a){switch(a){case 1:return"text";case 2:return"image";case 5:return"video";case 4:return"file";case 3:return"voice";default:return"unknown"}}function k(a){let e=a.split(".").map(s=>parseInt(s,10)),t=e[0]||0,r=e[1]||0,n=e[2]||0;return(t&255)<<16|(r&255)<<8|n&255}function B(a){if("file_item"in a)return a.file_item;if("image_item"in a)return a.image_item;if("voice_item"in a)return a.voice_item;if("video_item"in a)return a.video_item}function N(a){switch(a){case 2:case 3:case 4:case 5:return!0;case 0:case 1:default:return!1}}var h=class{config;clientVersionInt;constructor(e){this.config=m(_,e),this.clientVersionInt=k(e.version)}setToken(e){this.config.token=e}setBaseUrl(e){this.config.baseUrl=e}setUserId(e){this.config.userId=e}getBaseUrl(){return this.config.baseUrl}getBotType(){return this.config.botType}getUserId(){return this.config.userId}async request(e,t){let r=this.config.baseUrl.endsWith("/")?this.config.baseUrl:`${this.config.baseUrl}/`,n=new URL(e,r),s;if(t.method==="POST"&&t.body){let u={...t.body};u.base_info={channel_version:this.config.version},s=JSON.stringify(u)}else t.method==="POST"&&!t.body&&(s=JSON.stringify({base_info:{channel_version:this.config.version}}));let i=this.buildHeaders(s),o=new AbortController,c=setTimeout(()=>o.abort(),t.timeoutMs);try{let u=await fetch(n.toString(),{method:t.method,headers:i,body:s,signal:o.signal});clearTimeout(c);let l=await u.text();if(!u.ok)throw new Error(`WeChat API Error [${t.method} ${e}] HTTP ${u.status}: ${l}`);return l?JSON.parse(l):{}}catch(u){throw clearTimeout(c),u}}buildHeaders(e){let t={[g.CONTENT_TYPE]:w.CONTENT_TYPE_JSON,[g.AUTH_TYPE]:w.AUTH_TYPE_VALUE,[g.UIN]:d.randomWechatUin(),[g.APP_ID]:this.config.appId,[g.CLIENT_VERSION]:String(this.clientVersionInt)};return e&&(t["Content-Length"]=String(Buffer.byteLength(e,"utf-8"))),this.config.token?.trim()&&(t.Authorization=`Bearer ${this.config.token.trim()}`),t}};var C=class{core;constructor(e){this.core=e}async getQrCode(){this.core.setBaseUrl(this.core.getBaseUrl());let e=await this.core.request(`ilink/bot/get_bot_qrcode?bot_type=${this.core.getBotType()}`,{method:"GET",timeoutMs:1e4});return{qrcodeId:e.qrcode,qrcodeUrl:e.qrcode_img_content}}async pollLoginStatus(e,t=35e3){try{let r=await this.core.request(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(e)}`,{method:"GET",timeoutMs:t});return{status:r.status,data:r}}catch(r){if(r.name==="AbortError")return{status:"wait"};throw r}}async login(e){let t=e.maxRetries??3,r=0;for(;r<=t;){let n=await this.getQrCode();e.onQrCode(n);let s=Date.now()+3e5;for(;Date.now()<s;){let{status:i,data:o}=await this.pollLoginStatus(n.qrcodeId,e.pollTimeoutMs);switch(i){case"wait":e.onStatusChange?.({status:"wait",message:"\u7B49\u5F85\u626B\u7801..."});break;case"scaned":e.onStatusChange?.({status:"scaned",message:"\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u7AEF\u786E\u8BA4..."});break;case"scaned_but_redirect":if(o.redirect_host){let c=`https://${o.redirect_host}`;this.core.setBaseUrl(c),e.onStatusChange?.({status:"scaned_but_redirect",message:`\u670D\u52A1\u5668\u8981\u6C42\u91CD\u5B9A\u5411\u81F3: ${c}`,redirect_host:o.redirect_host})}break;case"confirmed":return e.onStatusChange?.({status:"confirmed",message:"\u767B\u5F55\u6210\u529F\uFF01",bot_token:o.bot_token,baseurl:o.baseurl,ilink_bot_id:o.ilink_bot_id,ilink_user_id:o.ilink_user_id}),this.core.setToken(o.bot_token),this.core.setUserId(o.ilink_user_id),o.baseurl&&this.core.setBaseUrl(o.baseurl),{token:o.bot_token,baseUrl:o.baseurl||this.core.getBaseUrl(),accountId:o.ilink_bot_id,userId:o.ilink_user_id};case"expired":e.onStatusChange?.({status:"expired",message:"\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u6B63\u5728\u91CD\u65B0\u83B7\u53D6..."});break;default:await new Promise(c=>setTimeout(c,1e3))}if(i==="expired")break;await new Promise(c=>setTimeout(c,1e3))}r++}throw new Error(`\u767B\u5F55\u5931\u8D25\uFF1A\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\u5E76\u8D85\u8FC7\u6700\u5927\u91CD\u8BD5\u6B21\u6570 (${t}\u6B21)\u3002`)}};import{readFile as K}from"fs/promises";import{basename as j}from"path";var I=class{core;cdn;constructor(e,t){this.core=e,this.cdn=t}async sendText(e,t=f){let r={type:1,text_item:{text:e}},n=m(f,{userId:this.core.getUserId()},t);return this._sendRawItem(r,n.userId,n.contextToken)}async sendImage(e,t=f){let r=m(f,{userId:this.core.getUserId()},t);return this._sendMediaWorkflow(e,1,r)}async sendVideo(e,t=f){let r=m(f,{userId:this.core.getUserId()},t);return this._sendMediaWorkflow(e,2,r)}async sendFile(e,t=f){let r=m(f,{userId:this.core.getUserId()},t);return this._sendMediaWorkflow(e,3,r)}async _sendMediaWorkflow(e,t,r){let n=await K(e),s=j(e),i=await this.cdn.uploadBuffer(n,r.userId,t);r.caption&&await this.sendText(r.caption,{contextToken:r.contextToken});let o={encrypt_query_param:i.encryptedQueryParam,aes_key:Buffer.from(i.aeskeyHex).toString("base64"),encrypt_type:1},c={};switch(t){case 1:c={type:2,image_item:{media:o,mid_size:i.fileSizeCipher}};break;case 2:c={type:5,video_item:{media:o,video_size:i.fileSizeCipher}};break;case 3:c={type:4,file_item:{media:o,file_name:s,len:String(i.fileSizePlain)}};break;default:throw new Error(`[MessageManager] \u4E0D\u652F\u6301\u7684\u5A92\u4F53\u7C7B\u578B: ${t}`)}return this._sendRawItem(c,r.userId,r.contextToken)}async _sendRawItem(e,t,r){let n=d.generateClientId(),s={msg:{from_user_id:"",to_user_id:t,client_id:n,message_type:2,message_state:2,context_token:r,item_list:[e]}},i=await this.core.request("ilink/bot/sendmessage",{method:"POST",body:s,timeoutMs:15e3});return{clientId:n,response:i}}};var E=class{core;config;constructor(e,t=M){this.core=e,this.config=m(M,t)}async uploadBuffer(e,t,r){let n=e.length,s=d.md5(e),i=d.getPaddedSize(n),o=d.generateRandomKey(16).toString("hex"),c=d.generateRandomKey(16),u=c.toString("hex"),l=await this.core.request("ilink/bot/getuploadurl",{method:"POST",body:{filekey:o,media_type:r,to_user_id:t,rawsize:n,rawfilemd5:s,filesize:i,no_need_thumb:!0,aeskey:u},timeoutMs:this.config.apiTimeoutMs}),y="";if(l.upload_full_url?.trim())y=l.upload_full_url.trim();else if(l.upload_param)y=`${this.config.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(l.upload_param)}&filekey=${encodeURIComponent(o)}`;else throw new Error("[CdnManager] \u83B7\u53D6\u4E0A\u4F20\u5730\u5740\u5931\u8D25\uFF0CAPI \u54CD\u5E94\u7F3A\u5C11 full_url \u6216 upload_param");let F=d.aesEcbEncrypt(e,c),v=await this.postToCdn(F,y);return{filekey:o,aeskeyHex:u,aeskeyBuffer:c,fileSizePlain:n,fileSizeCipher:i,encryptedQueryParam:v}}async postToCdn(e,t){let r=this.config.maxRetry,n;for(let s=1;s<=r;s++)try{let i=await fetch(t,{method:"POST",headers:{"Content-Type":"application/octet-stream"},body:new Uint8Array(e)});if(i.status>=400&&i.status<500){let c=i.headers.get(g.ERROR_MSG)??await i.text();throw new Error(`[CdnClientError] HTTP ${i.status}: ${c}`)}if(i.status!==200){let c=i.headers.get(g.ERROR_MSG)??`HTTP ${i.status}`;throw new Error(`[CdnServerError]: ${c}`)}let o=i.headers.get(g.ENCRYPTED_PARAM);if(!o)throw new Error(`[CdnManager] \u4E0A\u4F20\u6210\u529F\uFF0C\u4F46\u54CD\u5E94\u5934\u4E2D\u7F3A\u5C11 ${g.ENCRYPTED_PARAM}`);return o}catch(i){if(n=i,i.message&&i.message.includes("[CdnClientError]"))throw i;s<r&&await new Promise(o=>setTimeout(o,this.config.backoffBaseMs*s))}throw new Error(`[CdnManager] CDN \u4E0A\u4F20\u5931\u8D25\uFF0C\u5DF2\u91CD\u8BD5 ${r} \u6B21\u3002\u6700\u540E\u9519\u8BEF: ${n?.message}`)}async downloadBuffer(e){let t="";if(e.fullUrl)t=e.fullUrl;else if(e.encryptedQueryParam)t=`${this.config.cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(e.encryptedQueryParam)}`;else throw new Error("[CdnManager] \u4E0B\u8F7D\u5931\u8D25\uFF1A\u7F3A\u5C11 fullUrl \u548C encryptedQueryParam");let r=await this.fetchCdnBytes(t);if(e.isPlain||!e.aesKeyBase64)return r;let n=this.parseWechatAesKey(e.aesKeyBase64);return d.aesEcbDecrypt(r,n)}parseWechatAesKey(e){let t=Buffer.from(e,"base64");if(t.length===16)return t;if(t.length===32&&/^[0-9a-fA-F]{32}$/i.test(t.toString("ascii")))return Buffer.from(t.toString("ascii"),"hex");throw new Error(`[CdnManager] \u65E0\u6CD5\u89E3\u6790\u7684 AES \u5BC6\u94A5\u683C\u5F0F (Base64="${e}")`)}async fetchCdnBytes(e){let t=await fetch(e);if(!t.ok){let r=await t.text().catch(()=>"(unreadable)");throw new Error(`[CdnManager] CDN \u4E0B\u8F7D\u7F51\u7EDC\u9519\u8BEF HTTP ${t.status}: ${r}`)}return Buffer.from(await t.arrayBuffer())}};function Z(a,e){let t=a.byteLength,r=44+t,n=Buffer.allocUnsafe(r),s=0;return n.write("RIFF",s),s+=4,n.writeUInt32LE(r-8,s),s+=4,n.write("WAVE",s),s+=4,n.write("fmt ",s),s+=4,n.writeUInt32LE(16,s),s+=4,n.writeUInt16LE(1,s),s+=2,n.writeUInt16LE(1,s),s+=2,n.writeUInt32LE(e,s),s+=4,n.writeUInt32LE(e*2,s),s+=4,n.writeUInt16LE(2,s),s+=2,n.writeUInt16LE(16,s),s+=2,n.write("data",s),s+=4,n.writeUInt32LE(t,s),s+=4,Buffer.from(a.buffer,a.byteOffset,a.byteLength).copy(n,s),n}async function W(a,e=24e3){try{let{decode:t}=await import("silk-wasm"),r=await t(a,e);return Z(r.data,e)}catch{return null}}import{writeFile as ee}from"fs/promises";var T=class{cdn;config;constructor(e,t){this.cdn=e,this.config=t}async parse(e){if(!e.item_list||e.item_list.length===0)return[];let t=[],r={messageId:String(e.message_id),seq:e.seq,fromUserId:e.from_user_id,toUserId:e.to_user_id,timestamp:e.create_time_ms,contextToken:e.context_token,msgType:1,msgTypeStr:"unknown",index:0,raw:e};for(let n=0;n<e.item_list.length;n++){let s=e.item_list[n],i=m(r,{index:n,msgType:s.type,msgTypeStr:L(s.type)});if(s.type===1&&(i.text=s.text_item.text),!N(s.type)){t.push(i);continue}let o=null,c=this._extractDownloadTicket(s);if(i.fileName=c?.originalFileName,i.mediaTicket=c,i.getBuffer=async()=>o||(c?(o=await this.cdn.downloadBuffer(c),o):null),i.saveToFile=async u=>{let l=await i.getBuffer();if(!l)throw new Error("\u65E0\u5A92\u4F53\u5185\u5BB9\u53EF\u4FDD\u5B58");return await ee(u,l),u},i.msgType===3){i.getVoiceBuffer=i.getBuffer;let u=null;i.getBuffer=async()=>{if(u)return u;let l=await i.getVoiceBuffer();return l?(u=await W(l),u):null}}if(this.config.autoDownloadMedia!==!1&&c)try{i.getBuffer()}catch(u){console.error(`\u81EA\u52A8\u4E0B\u8F7D\u5A92\u4F53\u5931\u8D25: ${u.message}`)}t.push(i)}return t}_extractDownloadTicket(e){let t=B(e);if(!t||!t.media)return;let r=t.media;if(!r.encrypt_query_param&&!r.full_url)return;let n=r.aes_key;e.type===2&&"aeskey"in t&&t.aeskey&&(n=Buffer.from(t.aeskey,"hex").toString("base64"));let s=!n;return{fullUrl:r.full_url,encryptedQueryParam:r.encrypt_query_param,aesKeyBase64:n,isPlain:s,originalFileName:"file_name"in t?t.file_name:void 0}}};var P=class extends re{core;auth;messages;parser;cdn;currentCredentials=null;isPolling=!1;syncBuf="";constructor(e=_){super();let t=m(_,e);this.core=new h(t),this.auth=new C(this.core),this.cdn=new E(this.core),this.messages=new I(this.core,this.cdn),this.parser=new T(this.cdn,t),t.token&&(this.currentCredentials={token:t.token,baseUrl:t.baseUrl,accountId:"",userId:""})}async login(e=R){let t=m(R,e),r=await this.auth.login(t);return this.currentCredentials=r,this.emit("login",this.currentCredentials),this.currentCredentials}exportCredentials(){return!this.currentCredentials||!this.currentCredentials.token?null:{...this.currentCredentials}}loadCredentials(e){this.currentCredentials={...e},this.core.setToken(e.token),this.core.setBaseUrl(e.baseUrl),this.core.setUserId(e.userId),console.log(`[WeChatApi] \u6210\u529F\u52A0\u8F7D\u51ED\u8BC1 (AccountID: ${e.accountId})`)}async verifyCredentials(){if(!this.currentCredentials||!this.currentCredentials.token)return!1;try{let e=await this.core.request("ilink/bot/getconfig",{method:"POST",body:{ilink_user_id:this.currentCredentials.userId},timeoutMs:1e4});return e.ret!==void 0&&e.ret!==0?(console.warn(`[WeChatApi] \u51ED\u8BC1\u5DF2\u5931\u6548 (Server returned: ${e.errcode||e.ret})`),!1):e.errcode!==void 0&&e.errcode!==0?(console.warn(`[WeChatApi] \u51ED\u8BC1\u5DF2\u5931\u6548 (Server returned errcode: ${e.errcode}): ${e.errmsg}`),!1):!0}catch(e){return console.error("[WeChatApi] \u51ED\u8BC1\u9A8C\u8BC1\u5931\u8D25\uFF0C\u7F51\u7EDC\u5F02\u5E38\u6216\u5DF2\u8FC7\u671F:",e.message),!1}}async startPolling(){if(this.isPolling){console.warn("[WeChatApi] \u8F6E\u8BE2\u5DF2\u7ECF\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u52FF\u91CD\u590D\u542F\u52A8");return}if(!this.currentCredentials?.token)throw new Error("[WeChatApi] \u65E0\u6CD5\u542F\u52A8\u8F6E\u8BE2\uFF1A\u5C1A\u672A\u767B\u5F55\u6216\u672A\u52A0\u8F7D\u51ED\u8BC1");for(this.isPolling=!0,console.log("[WeChatApi] \u{1F680} \u8F6E\u8BE2\u5B88\u62A4\u8FDB\u7A0B\u5DF2\u542F\u52A8\uFF0C\u6B63\u5728\u76D1\u542C\u65B0\u6D88\u606F...");this.isPolling;)try{let e=await this.core.request("ilink/bot/getupdates",{method:"POST",body:{get_updates_buf:this.syncBuf},timeoutMs:35e3}),t=e.ret!==void 0&&e.ret!==0,r=e.errcode!==void 0&&e.errcode!==0;if(t||r){this.isPolling=!1,console.log(e),this.emit("error",new Error(`\u4F1A\u8BDD\u5DF2\u5931\u6548\u6216\u670D\u52A1\u7AEF\u62A5\u9519 (errcode: ${e.errcode})`)),console.error(`[WeChatApi] \u274C \u8F6E\u8BE2\u5F02\u5E38\u4E2D\u6B62\uFF1A${e.errmsg}`);break}if(e.get_updates_buf&&(this.syncBuf=e.get_updates_buf),e.msgs&&e.msgs.length>0)for(let n of e.msgs){let s=await this.parser.parse(n);if(!(!s||s.length===0))for(let i of s)this.emit("message",i),i.msgTypeStr!=="unknown"&&this.emit(i.msgTypeStr,i)}}catch(e){if(e.name==="AbortError"||e.message.includes("timeout"))continue;console.error(`[WeChatApi] \u26A0\uFE0F \u8F6E\u8BE2\u9047\u5230\u7F51\u7EDC\u5F02\u5E38\uFF0C3\u79D2\u540E\u91CD\u8BD5: ${e.message}`),await new Promise(t=>setTimeout(t,3e3))}console.log("[WeChatApi] \u{1F6D1} \u8F6E\u8BE2\u5B88\u62A4\u8FDB\u7A0B\u5DF2\u505C\u6B62\u3002")}stopPolling(){this.isPolling=!1}};export{S as LoginStatus,P as WeChatApi};
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/WechatAiApi.ts","../src/core/CryptoUtils.ts","../src/constants.ts","../src/types.ts","../src/core/utils.ts","../src/core/WeChatCore.ts","../src/managers/AuthManager.ts","../src/managers/MessageManager.ts","../src/managers/CdnManager.ts","../src/core/SilkConverter.ts","../src/managers/MessageParser.ts"],"sourcesContent":["import { EventEmitter } from \"node:events\";\n\nimport { WeChatCore } from \"./core/WeChatCore.ts\";\nimport { AuthManager } from \"./managers/AuthManager.ts\";\nimport { MessageManager } from \"./managers/MessageManager.ts\";\nimport { CdnManager } from \"./managers/CdnManager.ts\";\nimport { MessageParser } from \"./managers/MessageParser.ts\";\nimport { mergeObjects } from \"./core/utils.ts\";\nimport {\n DEFAULT_CLIENT_CONFIG,\n DEFAULT_LOGIN_OPTIONS,\n DEFAULT_REQUEST_TIMEOUT_MS,\n LONG_POLLING_TIMEOUT_MS,\n POLLING_ERROR_RETRY_DELAY_MS,\n} from \"./constants.ts\";\nimport type {\n LoginCredentials,\n LoginOptions,\n PollingResult,\n WeChatApiEventMap,\n WeChatClientConfig,\n} from \"./types.ts\";\n\nexport class WeChatApi extends EventEmitter<WeChatApiEventMap> {\n public readonly core: WeChatCore;\n public readonly auth: AuthManager;\n public readonly messages: MessageManager;\n public readonly parser: MessageParser;\n public readonly cdn: CdnManager;\n\n // 内部缓存当前的登录凭证\n private currentCredentials: LoginCredentials | null = null;\n\n private isPolling: boolean = false;\n private syncBuf: string = \"\"; // 极其重要:状态同步游标\n\n constructor(config: Partial<WeChatClientConfig> = DEFAULT_CLIENT_CONFIG) {\n super(); // 初始化 EventEmitter\n\n // 合并默认配置和用户配置\n const mergedConfig = mergeObjects(DEFAULT_CLIENT_CONFIG, config);\n\n // 初始化网络底层\n this.core = new WeChatCore(mergedConfig);\n\n // 挂载领域模块\n this.auth = new AuthManager(this.core);\n this.cdn = new CdnManager(this.core);\n this.messages = new MessageManager(this.core, this.cdn);\n this.parser = new MessageParser(this.cdn, mergedConfig);\n\n // 如果初始化时直接传入了 token,先暂存一份不完整的凭证\n if (mergedConfig.token) {\n this.currentCredentials = {\n token: mergedConfig.token,\n baseUrl: mergedConfig.baseUrl,\n accountId: \"\", // 初始化时可能未知\n userId: \"\", // 初始化时可能未知\n };\n }\n }\n\n // ==========================================\n // 1. 登录模块 (支持无脑调用 & 深度定制)\n // ==========================================\n\n /**\n * 启动扫码登录流程。\n * @param options 可选。如果为空,将使用默认的控制台交互体验。\n */\n public async login(\n options: Partial<LoginOptions> = DEFAULT_LOGIN_OPTIONS,\n ): Promise<LoginCredentials> {\n // 默认的“无脑”交互实现\n const defaultOptions = mergeObjects(DEFAULT_LOGIN_OPTIONS, options);\n\n // 执行底层的登录逻辑\n const result = await this.auth.login(defaultOptions);\n\n // 登录成功后,缓存在 Bot 实例中,方便随时导出\n this.currentCredentials = result;\n\n this.emit(\"login\", this.currentCredentials); // 触发全局登录事件\n return this.currentCredentials;\n }\n\n // ==========================================\n // 2. 凭证管理模块 (导入 / 导出 / 验证)\n // ==========================================\n\n /**\n * 导出当前的登录凭证。\n * 开发者拿到后可以自行保存到本地文件或数据库中。\n */\n public exportCredentials(): LoginCredentials | null {\n if (!this.currentCredentials || !this.currentCredentials.token) {\n return null;\n }\n // 返回一个深拷贝,防止外部修改污染内部状态\n return { ...this.currentCredentials };\n }\n\n /**\n * 加载现有的登录凭证。\n * 适用于程序重启后,免扫码直接恢复状态。\n */\n public loadCredentials(credentials: LoginCredentials): void {\n this.currentCredentials = { ...credentials };\n\n // 同步给底层 HTTP 引擎\n this.core.setToken(credentials.token);\n this.core.setBaseUrl(credentials.baseUrl);\n this.core.setUserId(credentials.userId);\n\n console.log(\n `[WeChatApi] 成功加载凭证 (AccountID: ${credentials.accountId})`,\n );\n }\n\n /**\n * 验证当前加载的凭证是否仍然有效。\n * @returns boolean 是否有效\n */\n public async verifyCredentials(): Promise<boolean> {\n if (!this.currentCredentials || !this.currentCredentials.token) {\n return false;\n }\n\n try {\n // 使用 getconfig 作为一个轻量级的 Ping 探针\n const response = await this.core.request<any>(\"ilink/bot/getconfig\", {\n method: \"POST\",\n body: {\n ilink_user_id: this.currentCredentials.userId,\n },\n timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,\n });\n\n if (response.ret !== undefined && response.ret !== 0) {\n console.warn(\n `[WeChatApi] 凭证已失效 (Server returned: ${\n response.errcode || response.ret\n })`,\n );\n return false;\n }\n\n if (response.errcode !== undefined && response.errcode !== 0) {\n console.warn(\n `[WeChatApi] 凭证已失效 (Server returned errcode: ${response.errcode}): ${response.errmsg}`,\n );\n return false;\n }\n\n return true;\n } catch (error) {\n console.error(\n \"[WeChatApi] 凭证验证失败,网络异常或已过期:\",\n (error as Error).message,\n );\n return false;\n }\n }\n\n // ==========================================\n // 轮询守护进程 (Daemon Loop)\n // ==========================================\n\n /**\n * 启动长轮询,监听新消息\n */\n public async startPolling() {\n if (this.isPolling) {\n console.warn(\"[WeChatApi] 轮询已经在运行中,请勿重复启动\");\n return;\n }\n\n if (!this.currentCredentials?.token) {\n throw new Error(\"[WeChatApi] 无法启动轮询:尚未登录或未加载凭证\");\n }\n\n this.isPolling = true;\n console.log(\"[WeChatApi] 🚀 轮询守护进程已启动,正在监听新消息...\");\n\n while (this.isPolling) {\n try {\n // 核心:发起长轮询请求。这里会挂起长达 35 秒,直到有新消息或超时\n const response = await this.core.request<PollingResult>(\n \"ilink/bot/getupdates\",\n {\n method: \"POST\",\n body: { get_updates_buf: this.syncBuf },\n timeoutMs: LONG_POLLING_TIMEOUT_MS, // 长轮询标准超时时间\n },\n );\n\n // 1. 检查服务端返回的错误码 (容错处理:成功时字段可能被省略)\n const hasErrorRet = response.ret !== undefined && response.ret !== 0;\n const hasErrcode = response.errcode !== undefined &&\n response.errcode !== 0;\n\n // 1. 检查服务端返回的错误码 (例如 -14 代表登录失效)\n if (hasErrorRet || hasErrcode) {\n this.isPolling = false;\n console.log(response);\n this.emit(\n \"error\",\n new Error(`会话已失效或服务端报错 (errcode: ${response.errcode})`),\n );\n console.error(`[WeChatApi] ❌ 轮询异常中止:${response.errmsg}`);\n break;\n }\n\n // 2. 更新同步游标 (无论有没有新消息,都要更新,否则会死循环拉取旧消息)\n if (response.get_updates_buf) {\n this.syncBuf = response.get_updates_buf;\n }\n\n // 3. 解析并分发新消息\n if (response.msgs && response.msgs.length > 0) {\n for (const rawMsg of response.msgs) {\n const parsedMsgs = await this.parser.parse(rawMsg);\n if (!parsedMsgs || parsedMsgs.length === 0) continue;\n\n // 触发全局通用消息事件\n for (const msg of parsedMsgs) {\n this.emit(\"message\", msg);\n if (msg.msgTypeStr !== \"unknown\") {\n this.emit(msg.msgTypeStr, msg as any);\n }\n }\n }\n }\n } catch (error: any) {\n // 4. 处理客户端超时 (正常现象,继续轮询)\n if (error.name === \"AbortError\" || error.message.includes(\"timeout\")) {\n // 仅仅是 35 秒内没人发消息而已,什么都不用做,继续进入下一个 while 循环\n continue;\n }\n\n // 处理真实的网络异常 (断网等),退避 3 秒后重试,防止 CPU 满载\n console.error(\n `[WeChatApi] ⚠️ 轮询遇到网络异常,3秒后重试: ${error.message}`,\n );\n await new Promise((resolve) =>\n setTimeout(resolve, POLLING_ERROR_RETRY_DELAY_MS)\n );\n }\n }\n\n console.log(\"[WeChatApi] 🛑 轮询守护进程已停止。\");\n }\n\n /**\n * 停止长轮询\n */\n public stopPolling() {\n this.isPolling = false;\n }\n}\n","// src/core/CryptoUtils.ts\n\nimport {\n createCipheriv,\n createDecipheriv,\n createHash,\n randomBytes,\n} from \"node:crypto\";\nimport { AES_BLOCK_SIZE, WECHAT_AES_ALGO } from \"../constants.ts\";\n\nexport class CryptoUtils {\n public static md5(buffer: Buffer): string {\n return createHash(\"md5\").update(buffer).digest(\"hex\");\n }\n\n public static generateRandomKey(length: number = 16): Buffer {\n return randomBytes(length);\n }\n\n public static getPaddedSize(plaintextSize: number): number {\n return Math.ceil((plaintextSize + 1) / AES_BLOCK_SIZE) * AES_BLOCK_SIZE;\n }\n\n public static aesEcbEncrypt(plaintext: Buffer, key: Buffer): Buffer {\n if (key.length !== AES_BLOCK_SIZE) {\n throw new Error(\n `[CryptoUtils] AES-128 密钥长度必须为 ${AES_BLOCK_SIZE} 字节,当前为 ${key.length} 字节`,\n );\n }\n const cipher = createCipheriv(WECHAT_AES_ALGO, key, null);\n return Buffer.concat([cipher.update(plaintext), cipher.final()]);\n }\n\n public static aesEcbDecrypt(ciphertext: Buffer, key: Buffer): Buffer {\n if (key.length !== AES_BLOCK_SIZE) {\n throw new Error(\n `[CryptoUtils] AES-128 密钥长度必须为 ${AES_BLOCK_SIZE} 字节,当前为 ${key.length} 字节`,\n );\n }\n const decipher = createDecipheriv(WECHAT_AES_ALGO, key, null);\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]);\n }\n\n /**\n * 生成去重的客户端消息 ID\n * 格式: prefix:timestamp-randomHex\n */\n public static generateClientId(): string {\n const randomHex = randomBytes(4).toString(\"hex\");\n return `wechat-bot:${Date.now()}-${randomHex}`;\n }\n\n /** 生成随机的 X-WECHAT-UIN (4字节随机数 -> uint32 -> base64) */\n public static randomWechatUin(): string {\n const uint32 = randomBytes(4).readUInt32BE(0);\n return Buffer.from(String(uint32), \"utf-8\").toString(\"base64\");\n }\n}\n","// src/constants.ts\n\nimport type {\n CdnManagerConfig,\n LoginOptions,\n SendMessageOptions,\n WeChatClientConfig,\n} from \"./types.ts\";\n\n// ====== Universal Constants ======\n\nexport const WECHAT_DEFAULT_BASE_URL = \"https://ilinkai.weixin.qq.com\";\nexport const WECHAT_DEFAULT_CDN_URL = \"https://cdn.weixin.qq.com\";\n\n// 获取二维码的普通 HTTP 请求超时 (10秒)\nexport const DEFAULT_REQUEST_TIMEOUT_MS = 10000;\n// 微信极其经典的长轮询 (Long-Polling) 超时基准线 (35秒)\nexport const LONG_POLLING_TIMEOUT_MS = 35000;\n// 轮询接口返回错误时的重试间隔 (3秒)\nexport const POLLING_ERROR_RETRY_DELAY_MS = 3000;\n// 获取上传门票的接口超时时间 (15秒)\nexport const DEFAULT_API_TIMEOUT_MS = 15000;\n// 二维码硬性失效的兜底时间 (5分钟)\nexport const QR_CODE_EXPIRATION_MS = 5 * 60 * 1000;\n// 轮询失败或异常时的退避时间 (1秒 防御性延迟)\nexport const BACKOFF_BASE_MS = 1000;\n// 获取二维码超时的重试次数\nexport const DEFAULT_LOGIN_MAX_RETRIES = 3;\n\n// AES-128 的密钥长度和块大小(Block Size)。\nexport const AES_BLOCK_SIZE = 16;\n// 微信 CDN 加密算法的硬性规定。\nexport const WECHAT_AES_ALGO = \"aes-128-ecb\";\n// 协议层魔数。微信用 1 来代表特定的 CDN 加密方式(极大概率指代 AES-128-ECB)\nexport const WECHAT_CDN_ENCRYPT_TYPE = 1;\n\n// 微信语音的标准采样率。虽然现在是定义在文件顶部的常量,但它属于协议级常量。\nexport const WECHAT_SILK_SAMPLE_RATE = 24000;\n\nexport const WECHAT_HTTP_HEADERS = {\n AUTH_TYPE: \"AuthorizationType\",\n UIN: \"X-WECHAT-UIN\",\n APP_ID: \"iLink-App-Id\",\n CLIENT_VERSION: \"iLink-App-ClientVersion\",\n CONTENT_TYPE: \"Content-Type\",\n AUTHORIZATION: \"Authorization\",\n ERROR_MSG: \"x-error-message\",\n ENCRYPTED_PARAM: \"x-encrypted-param\",\n} as const;\n\nexport const WECHAT_PROTOCOL = {\n AUTH_TYPE_VALUE: \"ilink_bot_token\",\n CONTENT_TYPE_JSON: \"application/json\",\n DEFAULT_TIMEOUT_MS: 30000,\n} as const;\n\n// ====== default options =======\n\nexport const DEFAULT_CLIENT_CONFIG: WeChatClientConfig = {\n appId: \"bot\",\n version: \"2.1.1\",\n baseUrl: WECHAT_DEFAULT_BASE_URL,\n botType: \"3\",\n autoDownloadMedia: true,\n};\n\nexport const DEFAULT_CDN_CONFIG: CdnManagerConfig = {\n maxRetry: 3,\n cdnBaseUrl: WECHAT_DEFAULT_CDN_URL,\n apiTimeoutMs: DEFAULT_API_TIMEOUT_MS,\n backoffBaseMs: BACKOFF_BASE_MS,\n};\n\nexport const DEFAULT_LOGIN_OPTIONS: LoginOptions = {\n maxRetries: DEFAULT_LOGIN_MAX_RETRIES,\n pollTimeoutMs: LONG_POLLING_TIMEOUT_MS,\n onQrCode: (qrInfo) => {\n console.log(\"\\n==========================================\");\n console.log(\"请在浏览器中打开以下链接,并使用微信扫码:\");\n console.log(qrInfo.qrcodeUrl);\n console.log(\"==========================================\\n\");\n },\n onStatusChange: (payload) => {\n switch (payload.status) {\n case \"wait\":\n console.log(\"等待登录...\"); // 简易的 loading 动画\n break;\n case \"scaned\":\n console.log(\"\\n👀 已扫码,请在手机微信上点击确认登录...\");\n break;\n case \"confirmed\":\n console.log(\n \"\\n已链接, 但需手动在微信发送第一条消息后, bot 才能正常回复\",\n );\n break;\n case \"scaned_but_redirect\":\n console.log(`\\n[系统] ${payload.message}`);\n break;\n }\n },\n};\n\nexport const DEFAULT_SEND_OPTIONS: SendMessageOptions = {\n contextToken: undefined,\n userId: undefined,\n caption: undefined,\n};\n","// src/types.ts\n\nexport interface WeChatClientConfig {\n /** 你的应用 ID (对应原 config.json 的 ilink_appid) */\n appId: string;\n /** 客户端版本号字符串,如 \"1.0.11\" */\n version: string;\n /** 基础 API 地址,通常为 https://ilinkai.weixin.qq.com */\n baseUrl: string;\n /** 默认机器人类型, 为 \"3\" */\n botType: string;\n /** 机器人的登录凭证,登录前可为空 */\n token?: string;\n /** 收到图片/文件/视频时,是否在后台自动下载解密到内存中?默认 true */\n autoDownloadMedia: boolean;\n /** 用户 ID */\n userId?: string;\n}\n\nexport interface CdnManagerConfig {\n // CDN 物理传输极易受到网络波动影响,固定死 3 次在网络差的环境下可能不够,在极速环境下又显得多余\n maxRetry: number;\n // \"https://cdn.weixin.qq.com\";\n cdnBaseUrl: string;\n apiTimeoutMs: number;\n backoffBaseMs: number;\n}\n\nexport interface SendMessageOptions {\n /** * 上下文 Token。用于在特定的会话上下文中回复消息\n */\n contextToken?: string;\n /** * 接收消息的用户 ID (微信 ID),目前仅对自身账号有效,如果不提供,SDK 会尝试使用登录用户的 ID 作为默认值\n */\n userId?: string;\n /** * 媒体附件的文字说明。\n * 注意:微信不支持图文混合在同一个 Item 中,\n * 如果提供此参数,SDK 会先发送一条文本消息,紧接着发送媒体消息。\n */\n caption?: string;\n}\n\n/** 核心引擎接收的请求参数 */\nexport interface CoreRequestOptions {\n method: \"GET\" | \"POST\";\n /** 纯业务请求体,无需包含 base_info */\n body?: Record<string, any>;\n /** 超时时间 (毫秒) */\n timeoutMs: number;\n}\n\nexport interface QrCodeInfo {\n qrcodeId: string;\n qrcodeUrl: string; // 用于生成图片的原始字符串/链接\n}\n\nexport interface LoginCredentials {\n token: string;\n baseUrl: string;\n accountId: string;\n userId: string;\n}\n\nexport type LogStatusChangePayload =\n | {\n status: LoginStatus.WAIT | LoginStatus.SCANNED | LoginStatus.EXPIRED;\n message: string;\n }\n | {\n status: LoginStatus.REDIRECT;\n message: string;\n redirect_host: string; // 服务器要求重定向的新 host\n }\n | {\n status: LoginStatus.CONFIRMED;\n message: string;\n bot_token: string; // 登录成功后返回的 token\n baseurl?: string; // 登录成功后返回的 baseUrl(如果有的话)\n ilink_bot_id: string; // 登录成功后返回的 bot 账号 ID\n ilink_user_id: string; // 登录成功后返回的用户 ID\n };\n\nexport interface LoginOptions {\n /** 当获取到新二维码时触发 (如果二维码过期会自动刷新并再次触发) */\n onQrCode: (qrInfo: QrCodeInfo) => void;\n /** 当轮询状态改变时触发 (可选) */\n onStatusChange: (payload: LogStatusChangePayload) => void;\n /** 二维码过期后最大重试次数,默认 3 */\n maxRetries: number;\n /** 单次长轮询的超时时间,默认 35000ms */\n pollTimeoutMs: number;\n}\n\nexport interface SendResult {\n clientId: string;\n response: any;\n}\n\n/** 内部流转的 CDN 文件凭据 (CdnManager 产出,MessageManager 消费) */\nexport interface CdnFileTicket {\n filekey: string;\n aeskeyHex: string; // 给 MessageManager 拼 JSON 用的 hex\n aeskeyBuffer: Buffer; // 未来下载解密用的 buffer\n fileSizePlain: number; // 明文大小\n fileSizeCipher: number; // 加密后大小\n encryptedQueryParam: string; // 核心下载参数\n}\n\n/** 统一的 CDN 下载票据 (抹平了图片、文件、视频的差异) */\nexport interface CdnDownloadTicket {\n fullUrl?: string; // 优先级 1\n encryptedQueryParam?: string; // 优先级 2\n aesKeyBase64?: string; // 密钥 (已统一转为 base64 处理好的)\n isPlain: boolean; // 是否是明文传输 (针对某些没有 aes_key 的图片)\n originalFileName?: string; // 针对文件\n}\n\nexport type ItemTypeStr =\n | \"text\"\n | \"image\"\n | \"video\"\n | \"file\"\n | \"voice\"\n | \"unknown\";\n\n// 对外暴露的消息对象结构\nexport interface WeChatIncomingMessage<\n T extends RawMessageItemBase = RawMessageItem,\n> {\n messageId: string;\n seq: number;\n fromUserId: string;\n toUserId: string;\n timestamp: number;\n contextToken: string; // 回复消息时必须带上这个字段\n msgType: T[\"type\"];\n msgTypeStr: ItemTypeStr; // 方便使用者直接判断类型的字符串版本\n /** 纯文本内容 */\n text?: string;\n /** 文件名 */\n fileName?: string;\n /**\n * 获取媒体文件的二进制 Buffer。\n * 如果开启了 autoDownloadMedia,此方法瞬间返回内存中的 Buffer。\n * 如果关闭了,此方法会发起网络请求下载并解密,然后缓存。\n */\n getBuffer?: () => Promise<Buffer | null>;\n /** 获取语音消息的二进制 Buffer, 这是原始的 SILK 编码 */\n getVoiceBuffer?: () => Promise<Buffer | null>;\n /**\n * 快捷方法:将媒体文件保存到本地磁盘。\n * @param savePath 指定绝对或相对路径\n */\n saveToFile?: (savePath: string) => Promise<string>;\n /** 原始底层票据 */\n mediaTicket?: CdnDownloadTicket;\n /** 消息在同一 seq 中的索引,方便开发者处理多 Item 的情况 */\n index: number;\n /** 原始消息载荷,给高级玩家使用 */\n raw: RawMessage<T>;\n}\n\nexport const enum MessageType {\n NONE = 0,\n USER = 1,\n BOT = 2,\n}\n\nexport const enum MessageItemType {\n NONE = 0,\n TEXT = 1,\n IMAGE = 2,\n VOICE = 3,\n FILE = 4,\n VIDEO = 5,\n}\n\nexport const enum MessageState {\n NEW = 0,\n GENERATING = 1,\n FINISH = 2,\n}\n\nexport const enum UploadMediaType {\n IMAGE = 1,\n VIDEO = 2,\n FILE = 3,\n VOICE = 4,\n}\n\nexport interface PollingResult {\n ret?: number | -2;\n errcode?: number;\n errmsg?: string;\n get_updates_buf?: string;\n msgs?: RawMessage[];\n}\n\nexport interface RawMessage<T extends RawMessageItemBase = RawMessageItem> {\n seq: number;\n message_id: number;\n from_user_id: string;\n to_user_id: string;\n client_id: string;\n create_time_ms: number;\n update_time_ms: number;\n delete_time_ms: number | 0;\n session_id: string | \"\";\n group_id: string | \"\";\n message_type: MessageType;\n message_state: MessageState;\n item_list: T[];\n context_token: string;\n}\n\nexport type RawMessageItem =\n | RawMessageTextItem\n | RawMessageFileItem\n | RawMessageImageItem\n | RawMessageVideoItem\n | RawMessageVoiceItem;\n\nexport interface RawMessageItemBase {\n type: MessageItemType;\n create_time_ms: number;\n update_time_ms: number;\n is_completed: boolean;\n}\n\nexport interface RawMessageTextItem extends RawMessageItemBase {\n type: MessageItemType.TEXT;\n text_item: { text: string };\n}\n\nexport interface RawMessageFileItem extends RawMessageItemBase {\n type: MessageItemType.FILE;\n file_item: {\n media: RawMessageMedia;\n file_name: string;\n md5: string;\n len: string;\n };\n}\n\nexport interface RawMessageImageItem extends RawMessageItemBase {\n type: MessageItemType.IMAGE;\n image_item: {\n url: string;\n aeskey: string;\n media: RawMessageMedia;\n mid_size: number;\n thumb_size: number;\n thumb_height: number;\n thumb_width: number;\n hd_size: number;\n };\n}\n\nexport interface RawMessageVideoItem extends RawMessageItemBase {\n type: MessageItemType.VIDEO;\n video_item: {\n media: RawMessageMedia;\n video_size: number;\n play_length: number;\n video_md5: string;\n thumb_media: RawMessageMedia;\n thumb_size: number;\n thumb_height: number;\n thumb_width: number;\n };\n}\n\nexport interface RawMessageVoiceItem extends RawMessageItemBase {\n type: MessageItemType.VOICE;\n voice_item: {\n media: RawMessageMedia;\n encode_type: number | 4;\n bits_per_sample: number | 16;\n sample_rate: number | 16000;\n playtime: number;\n text: string | \"\";\n };\n}\n\nexport interface RawMessageMedia {\n encrypt_query_param: string;\n aes_key: string;\n full_url: string;\n}\n\nexport interface WeChatApiEventMap {\n login: [credentials: LoginCredentials];\n message: [msg: WeChatIncomingMessage];\n text: [msg: WeChatIncomingMessage<RawMessageTextItem>];\n file: [msg: WeChatIncomingMessage<RawMessageFileItem>];\n image: [msg: WeChatIncomingMessage<RawMessageImageItem>];\n video: [msg: WeChatIncomingMessage<RawMessageVideoItem>];\n voice: [msg: WeChatIncomingMessage<RawMessageVoiceItem>];\n error: [error: Error];\n}\n\nexport const enum LoginStatus {\n WAIT = \"wait\",\n SCANNED = \"scaned\", // 保持和微信接口错别字一致\n REDIRECT = \"scaned_but_redirect\",\n CONFIRMED = \"confirmed\",\n EXPIRED = \"expired\",\n}\n","// src/core/utils.ts\nimport {\n type ItemTypeStr,\n MessageItemType,\n type RawMessageItem,\n} from \"../types.ts\";\n\nexport function mergeObjects<T>(ref: T, ...sources: Partial<T>[]): T {\n const target = { ...ref };\n for (const source of sources) {\n for (const key in source) {\n if (source[key] !== undefined) {\n target[key] = source[key] as T[Extract<keyof T, string>];\n }\n }\n }\n return target;\n}\n\nexport function getItemType(item: MessageItemType): ItemTypeStr {\n switch (item) {\n case MessageItemType.TEXT:\n return \"text\";\n case MessageItemType.IMAGE:\n return \"image\";\n case MessageItemType.VIDEO:\n return \"video\";\n case MessageItemType.FILE:\n return \"file\";\n case MessageItemType.VOICE:\n return \"voice\";\n default:\n return \"unknown\";\n }\n}\n\n/** 将 \"2.1.1\" 等版本号转换为 API 要求的数字位运算格式 */\nexport function buildClientVersion(version: string): number {\n const parts = version.split(\".\").map((p) => parseInt(p, 10));\n const major = parts[0] || 0;\n const minor = parts[1] || 0;\n const patch = parts[2] || 0;\n return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);\n}\n\nexport function getFileImageItem(item: RawMessageItem) {\n if (\"file_item\" in item) return item.file_item;\n if (\"image_item\" in item) return item.image_item;\n if (\"voice_item\" in item) return item.voice_item;\n if (\"video_item\" in item) return item.video_item;\n}\n\nexport function isItemWithMedia(\n item: MessageItemType,\n): item is\n | MessageItemType.FILE\n | MessageItemType.IMAGE\n | MessageItemType.VOICE\n | MessageItemType.VIDEO {\n switch (item) {\n case MessageItemType.IMAGE:\n case MessageItemType.VOICE:\n case MessageItemType.FILE:\n case MessageItemType.VIDEO:\n return true;\n case MessageItemType.NONE:\n case MessageItemType.TEXT:\n default:\n return false;\n }\n}\n","// src/core/WeChatCore.ts\nimport { CryptoUtils } from \"./CryptoUtils.ts\";\nimport { buildClientVersion, mergeObjects } from \"./utils.ts\";\nimport type { CoreRequestOptions, WeChatClientConfig } from \"../types.ts\";\nimport {\n DEFAULT_CLIENT_CONFIG,\n WECHAT_HTTP_HEADERS,\n WECHAT_PROTOCOL,\n} from \"../constants.ts\";\n\nexport class WeChatCore {\n private config: WeChatClientConfig;\n private clientVersionInt: number;\n\n constructor(config: WeChatClientConfig) {\n this.config = mergeObjects(DEFAULT_CLIENT_CONFIG, config);\n // 预计算版本号整数,避免每次发请求都算一遍\n this.clientVersionInt = buildClientVersion(config.version);\n }\n\n // ==========================================\n // 状态修改器 (Mutators)\n // ==========================================\n\n /** 设置或更新登录凭证 */\n public setToken(token: string): void {\n this.config.token = token;\n }\n\n /** 更新基础网关 URL (用于处理 IDC 重定向) */\n public setBaseUrl(baseUrl: string): void {\n this.config.baseUrl = baseUrl;\n }\n\n public setUserId(userId: string): void {\n this.config.userId = userId;\n }\n\n /** 获取当前的 BaseUrl (供某些需要拼接绝对路径的特殊场景使用) */\n public getBaseUrl(): string {\n return this.config.baseUrl;\n }\n\n /** 获取当前的机器人类型 */\n public getBotType(): string {\n return this.config.botType;\n }\n\n /** 获取当前的用户 ID */\n public getUserId(): string | undefined {\n return this.config.userId;\n }\n\n // ==========================================\n // 核心请求发射器\n // ==========================================\n\n /**\n * 统一的 HTTP 请求方法\n * @param endpoint 接口路径,例如 \"ilink/bot/sendmessage\"\n * @param options 请求配置 (方法、请求体、超时设置)\n */\n public async request<T>(\n endpoint: string,\n options: CoreRequestOptions,\n ): Promise<T> {\n // 1. URL 拼接处理\n const base = this.config.baseUrl.endsWith(\"/\")\n ? this.config.baseUrl\n : `${this.config.baseUrl}/`;\n const url = new URL(endpoint, base);\n\n // 2. 构造最终的请求体 (如果是 POST,自动注入 base_info)\n let finalBodyString: string | undefined = undefined;\n\n if (options.method === \"POST\" && options.body) {\n // 浅拷贝一层,防止污染调用方传入的原始对象\n const payload = { ...options.body };\n payload.base_info = { channel_version: this.config.version };\n finalBodyString = JSON.stringify(payload);\n } else if (options.method === \"POST\" && !options.body) {\n // 即使没有具体业务参数,POST 请求通常也需要 base_info\n finalBodyString = JSON.stringify({\n base_info: { channel_version: this.config.version },\n });\n }\n\n // 3. 构造请求头\n const headers = this.buildHeaders(finalBodyString);\n\n // 4. 设置超时控制器\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);\n\n // 5. 执行请求\n try {\n const response = await fetch(url.toString(), {\n method: options.method,\n headers: headers,\n body: finalBodyString,\n signal: controller.signal,\n });\n\n clearTimeout(timeoutId);\n const rawText = await response.text();\n\n // 非 200 状态码直接抛出异常,交给上层业务处理\n if (!response.ok) {\n throw new Error(\n `WeChat API Error [${options.method} ${endpoint}] HTTP ${response.status}: ${rawText}`,\n );\n }\n\n // 如果有返回值则解析,无返回值(如 sendmessage)则返回空对象\n return rawText ? JSON.parse(rawText) as T : ({} as T);\n } catch (err) {\n clearTimeout(timeoutId);\n // 将底层的网络异常原样抛出,如果是 AbortError (超时),上层(如长轮询)可以特别捕获它\n throw err;\n }\n }\n\n // ==========================================\n // 私有辅助方法\n // ==========================================\n\n /** 构造标准请求头 */\n private buildHeaders(bodyString?: string): Record<string, string> {\n const headers: Record<string, string> = {\n [WECHAT_HTTP_HEADERS.CONTENT_TYPE]: WECHAT_PROTOCOL.CONTENT_TYPE_JSON,\n [WECHAT_HTTP_HEADERS.AUTH_TYPE]: WECHAT_PROTOCOL.AUTH_TYPE_VALUE,\n [WECHAT_HTTP_HEADERS.UIN]: CryptoUtils.randomWechatUin(),\n [WECHAT_HTTP_HEADERS.APP_ID]: this.config.appId,\n [WECHAT_HTTP_HEADERS.CLIENT_VERSION]: String(this.clientVersionInt),\n };\n\n if (bodyString) {\n headers[\"Content-Length\"] = String(\n Buffer.byteLength(bodyString, \"utf-8\"),\n );\n }\n\n if (this.config.token?.trim()) {\n headers[\"Authorization\"] = `Bearer ${this.config.token.trim()}`;\n }\n\n return headers;\n }\n}\n","// src/managers/AuthManager.ts\nimport {\n BACKOFF_BASE_MS,\n DEFAULT_LOGIN_MAX_RETRIES,\n DEFAULT_REQUEST_TIMEOUT_MS,\n LONG_POLLING_TIMEOUT_MS,\n QR_CODE_EXPIRATION_MS,\n} from \"../constants.ts\";\nimport { WeChatCore } from \"../core/WeChatCore.ts\";\nimport {\n type LoginCredentials,\n type LoginOptions,\n LoginStatus,\n type QrCodeInfo,\n} from \"../types.ts\";\n\nexport class AuthManager {\n private core: WeChatCore;\n\n constructor(core: WeChatCore) {\n this.core = core;\n }\n\n // ==========================================\n // 细粒度 API: 第一步 - 获取二维码\n // ==========================================\n public async getQrCode(): Promise<QrCodeInfo> {\n // 强制使用默认的登录网关获取二维码\n this.core.setBaseUrl(this.core.getBaseUrl()); // 触发内部状态更新,确保使用默认 BaseUrl\n\n const response = await this.core.request<any>(\n `ilink/bot/get_bot_qrcode?bot_type=${this.core.getBotType()}`,\n { method: \"GET\", timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS },\n );\n\n return {\n qrcodeId: response.qrcode,\n qrcodeUrl: response.qrcode_img_content,\n };\n }\n\n // ==========================================\n // 细粒度 API: 第二步 - 单次轮询状态\n // ==========================================\n public async pollLoginStatus(\n qrcodeId: string,\n timeoutMs = LONG_POLLING_TIMEOUT_MS,\n ): Promise<{ status: LoginStatus; data?: any }> {\n try {\n const response = await this.core.request<any>(\n `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcodeId)}`,\n { method: \"GET\", timeoutMs },\n );\n return { status: response.status, data: response };\n } catch (err: any) {\n // 区分普通网络错误和长轮询的正常超时 (AbortError)\n if (err.name === \"AbortError\") {\n return { status: LoginStatus.WAIT }; // 超时视为继续等待\n }\n throw err;\n }\n }\n\n // ==========================================\n // 粗粒度 API: 一键自动托管登录\n // ==========================================\n public async login(options: LoginOptions): Promise<LoginCredentials> {\n const maxRetries = options.maxRetries ?? DEFAULT_LOGIN_MAX_RETRIES;\n let currentRetry = 0;\n\n while (currentRetry <= maxRetries) {\n // 1. 获取二维码\n const qrInfo = await this.getQrCode();\n options.onQrCode(qrInfo);\n\n // 2. 开始持续轮询该二维码\n // 设置一个总超时,防止死循环 (比如 5 分钟二维码一定会过期)\n const qrDeadline = Date.now() + QR_CODE_EXPIRATION_MS;\n\n while (Date.now() < qrDeadline) {\n const { status, data } = await this.pollLoginStatus(\n qrInfo.qrcodeId,\n options.pollTimeoutMs,\n );\n\n switch (status) {\n case LoginStatus.WAIT:\n options.onStatusChange?.({\n status: LoginStatus.WAIT,\n message: \"等待扫码...\",\n });\n break;\n\n case LoginStatus.SCANNED:\n options.onStatusChange?.({\n status: LoginStatus.SCANNED,\n message: \"已扫码,请在手机端确认...\",\n });\n break;\n\n case LoginStatus.REDIRECT:\n if (data.redirect_host) {\n const newBaseUrl = `https://${data.redirect_host}`;\n this.core.setBaseUrl(newBaseUrl); // 关键:更新底层引擎的 BaseUrl\n options.onStatusChange?.({\n status: LoginStatus.REDIRECT,\n message: `服务器要求重定向至: ${newBaseUrl}`,\n redirect_host: data.redirect_host,\n });\n }\n break;\n\n case LoginStatus.CONFIRMED:\n options.onStatusChange?.({\n status: LoginStatus.CONFIRMED,\n message: \"登录成功!\",\n bot_token: data.bot_token,\n baseurl: data.baseurl,\n ilink_bot_id: data.ilink_bot_id,\n ilink_user_id: data.ilink_user_id,\n });\n\n // 关键:自动将获取到的凭据注入到底层引擎中\n this.core.setToken(data.bot_token);\n this.core.setUserId(data.ilink_user_id);\n if (data.baseurl) {\n this.core.setBaseUrl(data.baseurl);\n }\n\n // 返回凭据给开发者,以便他们持久化存储\n return {\n token: data.bot_token,\n baseUrl: data.baseurl || this.core.getBaseUrl(),\n accountId: data.ilink_bot_id,\n userId: data.ilink_user_id,\n };\n\n case LoginStatus.EXPIRED:\n options.onStatusChange?.({\n status: LoginStatus.EXPIRED,\n message: \"二维码已过期,正在重新获取...\",\n });\n // 跳出内层轮询,触发外层循环重新获取二维码\n break;\n\n default:\n // 未知状态,稍作延迟后继续尝试\n await new Promise((res) => setTimeout(res, BACKOFF_BASE_MS));\n }\n\n if (status === LoginStatus.EXPIRED) {\n break; // 跳出内层 while 循环\n }\n\n // 防御性延迟,避免请求过于频繁\n await new Promise((res) => setTimeout(res, BACKOFF_BASE_MS));\n }\n\n currentRetry++;\n }\n\n throw new Error(\n `登录失败:二维码已过期并超过最大重试次数 (${maxRetries}次)。`,\n );\n }\n}\n","// src/managers/MessageManager.ts\n\nimport { readFile } from \"node:fs/promises\";\nimport { basename } from \"node:path\";\n\nimport { mergeObjects } from \"../core/utils.ts\";\nimport {\n DEFAULT_API_TIMEOUT_MS,\n DEFAULT_SEND_OPTIONS,\n WECHAT_CDN_ENCRYPT_TYPE,\n} from \"../constants.ts\";\nimport { CryptoUtils } from \"../core/CryptoUtils.ts\";\nimport {\n MessageItemType,\n MessageState,\n MessageType,\n type SendMessageOptions,\n type SendResult,\n UploadMediaType,\n} from \"../types.ts\";\nimport type { WeChatCore } from \"../core/WeChatCore.ts\";\nimport type { CdnManager } from \"./CdnManager.ts\";\n\nexport class MessageManager {\n private core: WeChatCore;\n private cdn: CdnManager;\n\n constructor(core: WeChatCore, cdn: CdnManager) {\n this.core = core;\n this.cdn = cdn;\n }\n\n // ==========================================\n // 公开 API: 发送纯文本\n // ==========================================\n\n public async sendText(\n text: string,\n options: Partial<SendMessageOptions> = DEFAULT_SEND_OPTIONS,\n ): Promise<SendResult> {\n const textItem = {\n type: MessageItemType.TEXT,\n text_item: { text },\n };\n const mergedOptions = mergeObjects(DEFAULT_SEND_OPTIONS, {\n userId: this.core.getUserId(),\n }, options);\n return this._sendRawItem(\n textItem,\n mergedOptions.userId!,\n mergedOptions.contextToken,\n );\n }\n\n // ==========================================\n // 公开 API: 发送媒体文件\n // ==========================================\n\n public async sendImage(\n filePath: string,\n options: Partial<SendMessageOptions> = DEFAULT_SEND_OPTIONS,\n ): Promise<SendResult> {\n const mergedOptions = mergeObjects(DEFAULT_SEND_OPTIONS, {\n userId: this.core.getUserId(),\n }, options);\n return this._sendMediaWorkflow(\n filePath,\n UploadMediaType.IMAGE,\n mergedOptions,\n );\n }\n\n public async sendVideo(\n filePath: string,\n options: Partial<SendMessageOptions> = DEFAULT_SEND_OPTIONS,\n ): Promise<SendResult> {\n const mergedOptions = mergeObjects(DEFAULT_SEND_OPTIONS, {\n userId: this.core.getUserId(),\n }, options);\n return this._sendMediaWorkflow(\n filePath,\n UploadMediaType.VIDEO,\n mergedOptions,\n );\n }\n\n /** - 仅允许发送文件, 发送**图片或视频**会导致发送失败!\n * - 发送图片和视频应使用 `sendImage` 或 `sendVideo` */\n public async sendFile(\n filePath: string,\n options: Partial<SendMessageOptions> = DEFAULT_SEND_OPTIONS,\n ): Promise<SendResult> {\n const mergedOptions = mergeObjects(DEFAULT_SEND_OPTIONS, {\n userId: this.core.getUserId(),\n }, options);\n return this._sendMediaWorkflow(\n filePath,\n UploadMediaType.FILE,\n mergedOptions,\n );\n }\n\n // ==========================================\n // 私有核心流水线\n // ==========================================\n\n /**\n * 统一的媒体发送工作流:读取本地文件 -> 上传 CDN -> (可选发送 Caption) -> 发送媒体消息\n */\n private async _sendMediaWorkflow(\n filePath: string,\n mediaType: UploadMediaType,\n options: SendMessageOptions,\n ): Promise<SendResult> {\n // 1. 读取本地文件为 Buffer\n const buffer = await readFile(filePath);\n const fileName = basename(filePath);\n\n // 2. 调用 CdnManager 上传至微信服务器,获取票据\n const ticket = await this.cdn.uploadBuffer(\n buffer,\n options.userId!,\n mediaType,\n );\n\n // 3. 如果用户传了 caption,先发送一条纯文本消息\n if (options.caption) {\n await this.sendText(options.caption, {\n contextToken: options.contextToken,\n });\n }\n\n // 4. 组装媒体 Item 载荷\n const mediaObj = {\n encrypt_query_param: ticket.encryptedQueryParam,\n aes_key: Buffer.from(ticket.aeskeyHex).toString(\"base64\"), // JSON 中要求 base64 格式\n encrypt_type: WECHAT_CDN_ENCRYPT_TYPE,\n };\n\n let messageItem: any = {};\n\n switch (mediaType) {\n case UploadMediaType.IMAGE:\n messageItem = {\n type: MessageItemType.IMAGE,\n image_item: { media: mediaObj, mid_size: ticket.fileSizeCipher },\n };\n break;\n case UploadMediaType.VIDEO:\n messageItem = {\n type: MessageItemType.VIDEO,\n video_item: { media: mediaObj, video_size: ticket.fileSizeCipher },\n };\n break;\n case UploadMediaType.FILE:\n messageItem = {\n type: MessageItemType.FILE,\n file_item: {\n media: mediaObj,\n file_name: fileName,\n len: String(ticket.fileSizePlain),\n }, // 注意 len 是明文大小的字符串\n };\n break;\n default:\n throw new Error(`[MessageManager] 不支持的媒体类型: ${mediaType}`);\n }\n\n // 5. 将组装好的媒体 Item 发送出去\n return this._sendRawItem(\n messageItem,\n options.userId!,\n options.contextToken,\n );\n }\n\n /**\n * 最底层的发包函数,将组装好的 Item 包装成完整的微信请求体并 POST\n */\n private async _sendRawItem(\n itemObj: any,\n userId: string,\n contextToken?: string,\n ): Promise<SendResult> {\n const clientId = CryptoUtils.generateClientId();\n\n const requestBody: any = {\n msg: {\n from_user_id: \"\", // 留空,微信网关会自动通过 Bearer Token 识别机器人身份\n to_user_id: userId,\n client_id: clientId,\n message_type: MessageType.BOT,\n message_state: MessageState.FINISH,\n context_token: contextToken,\n item_list: [itemObj],\n },\n };\n\n const response = await this.core.request(\"ilink/bot/sendmessage\", {\n method: \"POST\",\n body: requestBody,\n timeoutMs: DEFAULT_API_TIMEOUT_MS,\n });\n\n return { clientId, response };\n }\n}\n","// src/managers/CdnManager.ts\nimport { WeChatCore } from \"../core/WeChatCore.ts\";\nimport { CryptoUtils } from \"../core/CryptoUtils.ts\";\nimport {\n AES_BLOCK_SIZE,\n DEFAULT_CDN_CONFIG,\n WECHAT_HTTP_HEADERS,\n} from \"../constants.ts\";\nimport type {\n CdnDownloadTicket,\n CdnFileTicket,\n CdnManagerConfig,\n UploadMediaType,\n} from \"../types.ts\";\nimport { mergeObjects } from \"../core/utils.ts\";\n\nexport class CdnManager {\n private core: WeChatCore;\n private config: CdnManagerConfig;\n\n constructor(\n core: WeChatCore,\n config: Partial<CdnManagerConfig> = DEFAULT_CDN_CONFIG,\n ) {\n this.core = core;\n this.config = mergeObjects(DEFAULT_CDN_CONFIG, config);\n }\n\n // ==========================================\n // 核心流水线:上传二进制流到 CDN\n // ==========================================\n\n /**\n * 将任意 Buffer 上传至微信 CDN,并返回发送消息所需的凭证\n * @param buffer 文件的二进制原始数据\n * @param toUserId 接收人的微信 ID (CDN 强制要求绑定)\n * @param mediaType 媒体类型 (IMAGE, VIDEO, FILE, VOICE)\n */\n public async uploadBuffer(\n buffer: Buffer,\n toUserId: string,\n mediaType: UploadMediaType,\n ): Promise<CdnFileTicket> {\n // --------------------------------------------------\n // Step 1: 准备元数据与加密密钥\n // --------------------------------------------------\n const rawsize = buffer.length;\n const rawfilemd5 = CryptoUtils.md5(buffer);\n const filesize = CryptoUtils.getPaddedSize(rawsize); // AES 加密后的对齐大小\n\n // 微信要求 aeskey 和 filekey 都是 16 字节\n const filekey = CryptoUtils.generateRandomKey(AES_BLOCK_SIZE).toString(\n \"hex\",\n );\n const aeskeyBuffer = CryptoUtils.generateRandomKey(AES_BLOCK_SIZE);\n const aeskeyHex = aeskeyBuffer.toString(\"hex\");\n\n // --------------------------------------------------\n // Step 2: 访问控制面,申请上传门票 (Upload URL)\n // --------------------------------------------------\n const uploadUrlResp = await this.core.request<any>(\n \"ilink/bot/getuploadurl\",\n {\n method: \"POST\",\n body: {\n filekey,\n media_type: mediaType,\n to_user_id: toUserId,\n rawsize,\n rawfilemd5,\n filesize,\n no_need_thumb: true, // 📝 [待重构]: 暂时忽略缩略图逻辑,全部设为 true\n aeskey: aeskeyHex,\n },\n timeoutMs: this.config.apiTimeoutMs,\n },\n );\n\n // 解析出真实的 CDN 地址\n // 微信有时候返回完整的 URL (upload_full_url),有时候只返回 query 参数 (upload_param)\n let cdnUrl = \"\";\n if (uploadUrlResp.upload_full_url?.trim()) {\n cdnUrl = uploadUrlResp.upload_full_url.trim();\n } else if (uploadUrlResp.upload_param) {\n cdnUrl = `${this.config.cdnBaseUrl}/upload?encrypted_query_param=${\n encodeURIComponent(uploadUrlResp.upload_param)\n }&filekey=${encodeURIComponent(filekey)}`;\n } else {\n throw new Error(\n `[CdnManager] 获取上传地址失败,API 响应缺少 full_url 或 upload_param`,\n );\n }\n\n // --------------------------------------------------\n // Step 3: 数据面加密\n // --------------------------------------------------\n const ciphertextBuffer = CryptoUtils.aesEcbEncrypt(buffer, aeskeyBuffer);\n\n // --------------------------------------------------\n // Step 4: 物理传输至 CDN (带重试机制)\n // --------------------------------------------------\n const encryptedQueryParam = await this.postToCdn(ciphertextBuffer, cdnUrl);\n\n // --------------------------------------------------\n // Step 5: 组装凭证交付给 MessageManager\n // --------------------------------------------------\n return {\n filekey,\n aeskeyHex,\n aeskeyBuffer,\n fileSizePlain: rawsize,\n fileSizeCipher: filesize,\n encryptedQueryParam,\n };\n }\n\n // ==========================================\n // 私有发包器:专门应对奇葩的 CDN 接口\n // ==========================================\n\n /**\n * 将密文 POST 到 CDN,并从 Response Header 中抠出下载参数。\n * 包含 4xx 直接报错、5xx 重试的逻辑。\n */\n private async postToCdn(ciphertext: Buffer, cdnUrl: string): Promise<string> {\n const MAX_RETRIES = this.config.maxRetry;\n let lastError: unknown;\n\n for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {\n try {\n const res = await fetch(cdnUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/octet-stream\" },\n body: new Uint8Array(ciphertext),\n });\n\n // 1. 客户端错误 (4xx):比如鉴权失败、参数错误,重试也没用,直接抛出\n if (res.status >= 400 && res.status < 500) {\n const errMsg = res.headers.get(WECHAT_HTTP_HEADERS.ERROR_MSG) ??\n (await res.text());\n throw new Error(`[CdnClientError] HTTP ${res.status}: ${errMsg}`);\n }\n\n // 2. 服务端错误 (5xx):比如网关超时,尝试重试\n if (res.status !== 200) {\n const errMsg = res.headers.get(WECHAT_HTTP_HEADERS.ERROR_MSG) ??\n `HTTP ${res.status}`;\n throw new Error(`[CdnServerError]: ${errMsg}`);\n }\n\n // 3. 成功!从 Header 中寻找那把“钥匙”\n const downloadParam = res.headers.get(\n WECHAT_HTTP_HEADERS.ENCRYPTED_PARAM,\n );\n if (!downloadParam) {\n throw new Error(\n `[CdnManager] 上传成功,但响应头中缺少 ${WECHAT_HTTP_HEADERS.ENCRYPTED_PARAM}`,\n );\n }\n\n return downloadParam;\n } catch (err: any) {\n lastError = err;\n\n // 如果是 4xx 错误,立刻中断循环,不进行无意义的重试\n if (err.message && err.message.includes(\"[CdnClientError]\")) {\n throw err;\n }\n\n // 否则等待一会儿继续重试 (简易退避)\n if (attempt < MAX_RETRIES) {\n await new Promise((resolve) =>\n setTimeout(resolve, this.config.backoffBaseMs * attempt)\n );\n }\n }\n }\n\n throw new Error(\n `[CdnManager] CDN 上传失败,已重试 ${MAX_RETRIES} 次。最后错误: ${\n (lastError as Error)?.message\n }`,\n );\n }\n\n // ==========================================\n // 核心流水线:从 CDN 下载并解密\n // ==========================================\n\n /**\n * 唯一对内暴露的下载引擎\n * 负责拉取字节流并根据协议规范进行解密\n */\n public async downloadBuffer(ticket: CdnDownloadTicket): Promise<Buffer> {\n // 1. 确定最终的下载 URL (优先使用 fullUrl)\n let url = \"\";\n if (ticket.fullUrl) {\n url = ticket.fullUrl;\n } else if (ticket.encryptedQueryParam) {\n // 如果没有 fullUrl,就按照规则拼接\n url = `${this.config.cdnBaseUrl}/download?encrypted_query_param=${\n encodeURIComponent(ticket.encryptedQueryParam)\n }`;\n } else {\n throw new Error(\n \"[CdnManager] 下载失败:缺少 fullUrl 和 encryptedQueryParam\",\n );\n }\n\n // 2. 发起网络请求,拉取原始的 ArrayBuffer\n const encryptedBuffer = await this.fetchCdnBytes(url);\n\n // 3. 明文回退:如果协议表明这是明文传输(没有密钥),直接返回\n if (ticket.isPlain || !ticket.aesKeyBase64) {\n return encryptedBuffer;\n }\n\n // 4. 解析变态的微信 AES 密钥\n const aesKey = this.parseWechatAesKey(ticket.aesKeyBase64);\n\n // 5. 使用密码学工具箱进行 AES-128-ECB 解密\n return CryptoUtils.aesEcbDecrypt(encryptedBuffer, aesKey);\n }\n\n // ==========================================\n // 私有辅助方法\n // ==========================================\n\n /**\n * 处理微信极其不一致的密钥编码:\n * 情况A: base64( raw 16 bytes )\n * 情况B: base64( hex string of 16 bytes )\n */\n private parseWechatAesKey(aesKeyBase64: string): Buffer {\n const decoded = Buffer.from(aesKeyBase64, \"base64\");\n\n // 如果解密出来正好是 16 字节的二进制,直接用\n if (decoded.length === 16) {\n return decoded;\n }\n\n // 如果解密出来是 32 个字符,并且全是十六进制字符,说明它被二次 Hex 编码了\n if (\n decoded.length === 32 &&\n /^[0-9a-fA-F]{32}$/i.test(decoded.toString(\"ascii\"))\n ) {\n return Buffer.from(decoded.toString(\"ascii\"), \"hex\");\n }\n\n throw new Error(\n `[CdnManager] 无法解析的 AES 密钥格式 (Base64=\"${aesKeyBase64}\")`,\n );\n }\n\n /**\n * 原生 fetch 拉取二进制字节流\n */\n private async fetchCdnBytes(url: string): Promise<Buffer> {\n const res = await fetch(url);\n if (!res.ok) {\n const body = await res.text().catch(() => \"(unreadable)\");\n throw new Error(\n `[CdnManager] CDN 下载网络错误 HTTP ${res.status}: ${body}`,\n );\n }\n return Buffer.from(await res.arrayBuffer());\n }\n}\n","// src/core/SilkConverter.ts\n\nimport { WECHAT_SILK_SAMPLE_RATE } from \"../constants.ts\";\n\n/**\n * Wrap raw pcm_s16le bytes in a WAV container.\n * Mono channel, 16-bit signed little-endian.\n */\nfunction pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {\n const pcmBytes = pcm.byteLength;\n const totalSize = 44 + pcmBytes;\n const buf = Buffer.allocUnsafe(totalSize);\n let offset = 0;\n\n buf.write(\"RIFF\", offset);\n offset += 4;\n buf.writeUInt32LE(totalSize - 8, offset);\n offset += 4;\n buf.write(\"WAVE\", offset);\n offset += 4;\n\n buf.write(\"fmt \", offset);\n offset += 4;\n buf.writeUInt32LE(16, offset);\n offset += 4; // fmt chunk size\n buf.writeUInt16LE(1, offset);\n offset += 2; // PCM format\n buf.writeUInt16LE(1, offset);\n offset += 2; // mono\n buf.writeUInt32LE(sampleRate, offset);\n offset += 4;\n buf.writeUInt32LE(sampleRate * 2, offset);\n offset += 4; // byte rate (mono 16-bit)\n buf.writeUInt16LE(2, offset);\n offset += 2; // block align\n buf.writeUInt16LE(16, offset);\n offset += 2; // bits per sample\n\n buf.write(\"data\", offset);\n offset += 4;\n buf.writeUInt32LE(pcmBytes, offset);\n offset += 4;\n\n Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);\n\n return buf;\n}\n\nexport async function silkToWav(\n silkBuffer: Buffer,\n sampleRate: number = WECHAT_SILK_SAMPLE_RATE,\n): Promise<Buffer | null> {\n try {\n const { decode } = await import(\"silk-wasm\");\n const result = await decode(silkBuffer, sampleRate);\n const wav = pcmBytesToWav(result.data, sampleRate);\n return wav;\n } catch (err) {\n return null;\n }\n}\n","// src/managers/MessageParser.ts\n\nimport type { CdnManager } from \"./CdnManager.ts\";\nimport {\n type CdnDownloadTicket,\n MessageItemType,\n type RawMessage,\n type RawMessageItem,\n type WeChatClientConfig,\n type WeChatIncomingMessage,\n} from \"../types.ts\";\nimport { silkToWav } from \"../core/SilkConverter.ts\";\nimport { writeFile } from \"node:fs/promises\";\nimport {\n getFileImageItem,\n getItemType,\n isItemWithMedia,\n mergeObjects,\n} from \"../core/utils.ts\";\n\nexport class MessageParser {\n private cdn: CdnManager;\n private config: WeChatClientConfig;\n\n constructor(cdn: CdnManager, config: WeChatClientConfig) {\n this.cdn = cdn;\n this.config = config;\n }\n\n /**\n * 将微信底层的复杂 JSON 扁平化为开发者友好的对象\n */\n public async parse(raw: RawMessage): Promise<WeChatIncomingMessage[] | null> {\n // 过滤掉系统消息或无内容的空包\n if (!raw.item_list || raw.item_list.length === 0) return [];\n\n const results: WeChatIncomingMessage[] = [];\n\n // 提取公共的信封数据\n const shared_data: WeChatIncomingMessage = {\n messageId: String(raw.message_id),\n seq: raw.seq,\n fromUserId: raw.from_user_id,\n toUserId: raw.to_user_id,\n timestamp: raw.create_time_ms,\n contextToken: raw.context_token,\n msgType: MessageItemType.TEXT,\n msgTypeStr: \"unknown\",\n index: 0,\n raw: raw,\n };\n\n for (let i = 0; i < raw.item_list.length; i++) {\n const item = raw.item_list[i];\n const msgCopy = mergeObjects(shared_data, {\n index: i,\n msgType: item.type,\n msgTypeStr: getItemType(item.type),\n });\n\n // 1. 先处理文本消息,提取纯文本内容\n if (item.type === MessageItemType.TEXT) {\n msgCopy.text = item.text_item.text;\n }\n if (!isItemWithMedia(item.type)) {\n results.push(msgCopy);\n continue; // 如果不是媒体消息,直接进入下一轮循环\n }\n\n // 2. 媒体消息需要提取下载票据,并提供 getBuffer 方法\n let cachedBuffer: Buffer | null = null;\n const ticket = this._extractDownloadTicket(item);\n msgCopy.fileName = ticket?.originalFileName;\n msgCopy.mediaTicket = ticket;\n msgCopy.getBuffer = async () => {\n if (cachedBuffer) return cachedBuffer;\n if (!ticket) return null;\n // 调用 CDN 管理器下载\n cachedBuffer = await this.cdn.downloadBuffer(ticket);\n return cachedBuffer;\n };\n msgCopy.saveToFile = async (savePath: string) => {\n const buf = await msgCopy.getBuffer!();\n if (!buf) throw new Error(\"无媒体内容可保存\");\n await writeFile(savePath, buf);\n return savePath;\n };\n\n // 3. 语音消息需要特殊处理,提供一个额外的方法获取原始 SILK 编码的 Buffer\n if (msgCopy.msgType === MessageItemType.VOICE) {\n msgCopy.getVoiceBuffer = msgCopy.getBuffer; // 语音消息的原始 Buffer 是 SILK 编码\n let pcmCachedBuffer: Buffer | null = null;\n msgCopy.getBuffer = async () => {\n if (pcmCachedBuffer) return pcmCachedBuffer;\n const silk_buffer = await msgCopy.getVoiceBuffer!();\n if (!silk_buffer) return null;\n pcmCachedBuffer = await silkToWav(silk_buffer);\n return pcmCachedBuffer;\n };\n }\n\n // 4. 如果开启了自动下载,就在抛出事件前,在后台先下载好!\n if (this.config.autoDownloadMedia !== false && ticket) {\n try {\n msgCopy.getBuffer(); // 不 await,后台下载,事件照常触发\n } catch (err: any) {\n console.error(`自动下载媒体失败: ${err.message}`);\n }\n }\n results.push(msgCopy);\n }\n return results;\n }\n\n /**\n * 从原始 JSON 中提取标准化的 CDN 下载票据\n */\n private _extractDownloadTicket(\n item: RawMessageItem,\n ): CdnDownloadTicket | undefined {\n const file_item = getFileImageItem(item);\n if (!file_item || !file_item.media) return undefined;\n const media = file_item.media;\n\n // 如果连基本的下载参数都没有,直接放弃\n if (!media.encrypt_query_param && !media.full_url) return undefined;\n\n // 2. 抹平 AES 密钥的特权差异\n let aesKeyBase64 = media.aes_key;\n\n // ⚠️ 协议特例:图片类型有时会把密钥放在外层,而且是 Hex 格式\n if (\n item.type === MessageItemType.IMAGE && \"aeskey\" in file_item &&\n file_item.aeskey\n ) {\n // 统一转成 Base64,方便 CdnManager 统一处理\n aesKeyBase64 = Buffer.from(file_item.aeskey, \"hex\").toString(\"base64\");\n }\n\n // 3. 判断是否为明文传输 (某些情况下微信图片没有 aes_key)\n const isPlain = !aesKeyBase64;\n\n return {\n fullUrl: media.full_url,\n encryptedQueryParam: media.encrypt_query_param,\n aesKeyBase64,\n isPlain,\n originalFileName: \"file_name\" in file_item\n ? file_item.file_name\n : undefined,\n };\n }\n}\n"],"mappings":"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","names":["EventEmitter","createCipheriv","createDecipheriv","createHash","randomBytes","WECHAT_DEFAULT_BASE_URL","WECHAT_DEFAULT_CDN_URL","WECHAT_AES_ALGO","WECHAT_HTTP_HEADERS","WECHAT_PROTOCOL","DEFAULT_CLIENT_CONFIG","WECHAT_DEFAULT_BASE_URL","DEFAULT_CDN_CONFIG","WECHAT_DEFAULT_CDN_URL","DEFAULT_LOGIN_OPTIONS","qrInfo","payload","DEFAULT_SEND_OPTIONS","CryptoUtils","buffer","createHash","length","randomBytes","plaintextSize","plaintext","key","cipher","createCipheriv","WECHAT_AES_ALGO","ciphertext","decipher","createDecipheriv","randomHex","uint32","LoginStatus","mergeObjects","ref","sources","target","source","key","getItemType","item","buildClientVersion","version","parts","p","major","minor","patch","getFileImageItem","isItemWithMedia","WeChatCore","config","mergeObjects","DEFAULT_CLIENT_CONFIG","buildClientVersion","token","baseUrl","userId","endpoint","options","base","url","finalBodyString","payload","headers","controller","timeoutId","response","rawText","err","bodyString","WECHAT_HTTP_HEADERS","WECHAT_PROTOCOL","CryptoUtils","AuthManager","core","response","qrcodeId","timeoutMs","err","options","maxRetries","currentRetry","qrInfo","qrDeadline","status","data","newBaseUrl","res","readFile","basename","MessageManager","core","cdn","text","options","DEFAULT_SEND_OPTIONS","textItem","mergedOptions","mergeObjects","filePath","mediaType","buffer","readFile","fileName","basename","ticket","mediaObj","messageItem","itemObj","userId","contextToken","clientId","CryptoUtils","requestBody","response","CdnManager","core","config","DEFAULT_CDN_CONFIG","mergeObjects","buffer","toUserId","mediaType","rawsize","rawfilemd5","CryptoUtils","filesize","filekey","aeskeyBuffer","aeskeyHex","uploadUrlResp","cdnUrl","ciphertextBuffer","encryptedQueryParam","ciphertext","MAX_RETRIES","lastError","attempt","res","errMsg","WECHAT_HTTP_HEADERS","downloadParam","err","resolve","ticket","url","encryptedBuffer","aesKey","aesKeyBase64","decoded","body","pcmBytesToWav","pcm","sampleRate","pcmBytes","totalSize","buf","offset","silkToWav","silkBuffer","decode","result","writeFile","MessageParser","cdn","config","raw","results","shared_data","i","item","msgCopy","mergeObjects","getItemType","isItemWithMedia","cachedBuffer","ticket","savePath","buf","writeFile","pcmCachedBuffer","silk_buffer","silkToWav","err","file_item","getFileImageItem","media","aesKeyBase64","isPlain","WeChatApi","EventEmitter","config","DEFAULT_CLIENT_CONFIG","mergedConfig","mergeObjects","WeChatCore","AuthManager","CdnManager","MessageManager","MessageParser","options","DEFAULT_LOGIN_OPTIONS","defaultOptions","result","credentials","response","error","hasErrorRet","hasErrcode","rawMsg","parsedMsgs","msg","resolve"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aluria/wechat-ai-api",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "一个优雅、轻量、开箱即用的个人微信通知/机器人 SDK",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"wechat",
|
|
7
|
+
"bot",
|
|
8
|
+
"sdk",
|
|
9
|
+
"openclaw-wechat",
|
|
10
|
+
"api",
|
|
11
|
+
"notification"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/你的用户名/wechat-ai-api.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Aluria",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"types": "dist/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"prepare": "npm run build",
|
|
29
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"silk-wasm": "^3.7.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"tsup": "^8.5.1",
|
|
37
|
+
"typescript": "^5.9.3"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# @aluria/wechat-ai-api
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@aluria/wechat-ai-api)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
|
|
7
|
+
基于微信 OpenClaw 开放接口规范封装的 Node.js SDK。为开发者提供标准化的 API,用于快速接入微信扫码鉴权、长轮询消息监听、以及富媒体(文本、图片、文件、视频、语音)的自动化收发。
|
|
8
|
+
|
|
9
|
+
🔗 **GitHub 仓库**: [https://github.com/Wu-Yijun/wechat-ai-api](https://github.com/Wu-Yijun/wechat-ai-api)
|
|
10
|
+
|
|
11
|
+
## 核心特性
|
|
12
|
+
|
|
13
|
+
- **完整的生命周期管理**:封装了完整的扫码鉴权、凭证持久化及长轮询(Long-Polling)保活机制。
|
|
14
|
+
- **富媒体透明处理**:内置 `CdnManager`,在发送媒体文件时自动完成 AES-128-ECB 加密与微信 CDN 上传;接收时自动获取凭据并完成解密。
|
|
15
|
+
- **按需加载机制**:支持将收到的媒体资源暂存于内存中(惰性求值),支持随时转存为本地文件或直接提取为 Buffer。
|
|
16
|
+
- **类型安全**:基于 TypeScript 编写,提供严格的接口约束与完整的类型定义(`.d.ts`)。
|
|
17
|
+
|
|
18
|
+
## 📦 安装
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @aluria/wechat-ai-api
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
*注:本项目使用了纯 ESM 规范发布,请确保您的 Node.js 项目 (`package.json`) 中已配置 `"type": "module"`。*
|
|
25
|
+
|
|
26
|
+
## 基础使用
|
|
27
|
+
|
|
28
|
+
### 1\. 鉴权与会话恢复
|
|
29
|
+
|
|
30
|
+
SDK 支持将登录成功后的凭据(Token、账户 ID 等)导出,以便在 Node.js 进程重启时免扫码恢复连接。
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { WeChatApi } from "@aluria/wechat-ai-api";
|
|
34
|
+
import fs from "node:fs";
|
|
35
|
+
|
|
36
|
+
const bot = new WeChatApi();
|
|
37
|
+
|
|
38
|
+
async function init() {
|
|
39
|
+
const sessionPath = "./session.json";
|
|
40
|
+
|
|
41
|
+
if (fs.existsSync(sessionPath)) {
|
|
42
|
+
// 恢复历史会话
|
|
43
|
+
const credentials = JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
|
|
44
|
+
bot.loadCredentials(credentials);
|
|
45
|
+
console.log("已恢复历史会话");
|
|
46
|
+
} else {
|
|
47
|
+
// 首次扫码登录
|
|
48
|
+
const credentials = await bot.login();
|
|
49
|
+
fs.writeFileSync(sessionPath, JSON.stringify(credentials));
|
|
50
|
+
console.log("登录成功,凭据已保存");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 启动消息监听守护进程
|
|
54
|
+
bot.startPolling();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
init();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 2\. 消息监听与处理
|
|
61
|
+
|
|
62
|
+
通过继承 `EventEmitter` 的事件系统,您可以对不同类型的消息进行精确路由。
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// 监听纯文本消息
|
|
66
|
+
bot.on("text", async (msg) => {
|
|
67
|
+
console.log(`[Text] 收到来自 ${msg.fromUserId} 的消息: ${msg.text}`);
|
|
68
|
+
|
|
69
|
+
if (msg.text === "ping") {
|
|
70
|
+
// 使用 contextToken 触发引用回复
|
|
71
|
+
await bot.messages.sendText("pong", { contextToken: msg.contextToken });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 监听图片消息
|
|
76
|
+
bot.on("image", async (msg) => {
|
|
77
|
+
console.log(`[Image] 收到图片消息,ID: ${msg.messageId}`);
|
|
78
|
+
|
|
79
|
+
// 方式 A:直接保存到本地磁盘
|
|
80
|
+
const savedPath = await msg.saveToFile!(`./downloads/${msg.messageId}.jpg`);
|
|
81
|
+
console.log(`图片已落盘: ${savedPath}`);
|
|
82
|
+
|
|
83
|
+
// 方式 B:获取 Buffer 传递给第三方 AI 服务(如图像识别)
|
|
84
|
+
// const imageBuffer = await msg.getBuffer!();
|
|
85
|
+
// await externalVisionService.analyze(imageBuffer);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// 监听文件接收
|
|
89
|
+
bot.on("file", async (msg) => {
|
|
90
|
+
console.log(`[File] 收到文件: ${msg.fileName}`);
|
|
91
|
+
await msg.saveToFile!(`./downloads/${msg.fileName}`);
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 3\. 主动发送消息
|
|
96
|
+
|
|
97
|
+
`bot.messages` 命名空间提供了各种媒体的快捷下发接口。
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// 发送带标题的文件
|
|
101
|
+
await bot.messages.sendFile("./reports/weekly.pdf", {
|
|
102
|
+
caption: "本周的数据报表已生成",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// 发送视频
|
|
106
|
+
await bot.messages.sendVideo("./assets/demo.mp4");
|
|
107
|
+
|
|
108
|
+
// 向指定用户发送图文消息
|
|
109
|
+
await bot.messages.sendImage("./assets/alert.png", {
|
|
110
|
+
caption: "系统触发了阈值警报"
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## ⚙️ 高级配置 (Configuration)
|
|
115
|
+
|
|
116
|
+
在实例化 `WeChatApi` 时,可传入自定义配置以覆盖默认行为:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
const bot = new WeChatApi({
|
|
120
|
+
// 是否在接收到图片/文件时自动在后台静默下载并解密缓存到内存中
|
|
121
|
+
// 设为 false 可大幅降低带宽消耗,后续可以手动触发下载
|
|
122
|
+
autoDownloadMedia: true,
|
|
123
|
+
|
|
124
|
+
// 可指定特定的网关地址
|
|
125
|
+
baseUrl: "https://ilinkai.weixin.qq.com",
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 自定义登录交互
|
|
130
|
+
|
|
131
|
+
默认的 `login()` 方法会在控制台打印纯文本的二维码 URL。如果您需要将二维码渲染到 Web 页面或通过其他渠道推送,可传入自定义的回调函数:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
await bot.login({
|
|
135
|
+
onQrCode: (qrInfo) => {
|
|
136
|
+
// qrcodeUrl 为二维码解析后的文本内容
|
|
137
|
+
// 您可以使用 qrcode 库将其渲染为 base64 图片或在终端输出点阵图
|
|
138
|
+
console.log("获取到授权二维码:", qrInfo.qrcodeUrl);
|
|
139
|
+
},
|
|
140
|
+
onStatusChange: (payload) => {
|
|
141
|
+
// 状态流转:wait -> scanned -> confirmed
|
|
142
|
+
console.log(`授权状态更新: ${payload.status}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 🛠️ 技术细节与实现约束
|
|
148
|
+
|
|
149
|
+
1. **CDN 加解密规范**:微信媒体文件传输采用 `AES-128-ECB` 算法。本项目已完整实现了该加密规范,包括密钥的 Base64 与 Hex 双重编码容错处理。
|
|
150
|
+
2. **长轮询机制**:消息接收依赖 HTTP Long-Polling,默认连接维持时间为 `35,000` 毫秒。SDK 内部维护了 `sync_buf` 游标,有效防止了消息丢失与重复拉取。
|
|
151
|
+
3. **音频编码**:微信语音消息采用私有的 SILK 格式。已集成 `silk-wasm` 进行音频格式的转换。
|
|
152
|
+
|
|
153
|
+
## 📖 API 参考
|
|
154
|
+
|
|
155
|
+
详细的类型定义与接口描述,请参阅源码中的 `types.ts`。主要模块包括:
|
|
156
|
+
|
|
157
|
+
- `WeChatApi`: 核心实例,统筹鉴权与轮询。
|
|
158
|
+
- `WeChatApi.messages`: 提供 `sendText`, `sendImage`, `sendVideo`, `sendFile` 等方法。
|
|
159
|
+
- `WeChatIncomingMessage`: 暴露于事件回调中的消息体,提供 `saveToFile()` 和 `getBuffer()`。
|
|
160
|
+
|
|
161
|
+
更多详细示例代码请查阅代码库中的 [`example/`](https://github.com/Wu-Yijun/wechat-ai-api/tree/main/example) 目录。
|
|
162
|
+
|
|
163
|
+
## 📄 免责声明与协议
|
|
164
|
+
|
|
165
|
+
本项目基于 **MIT** 协议开源。
|
|
166
|
+
|
|
167
|
+
**声明:** 本 SDK 仅供技术研究、个人日常学习与自动化辅助使用。请严格遵守《腾讯微信软件许可及服务协议》。使用本 SDK 造成的任何账号封禁、数据丢失或法律纠纷,开发者概不负责。严禁用于批量营销、黑灰产及任何侵犯用户隐私的商业行为。
|