wechat-ilink-client 0.1.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/README.md +313 -0
- package/README.zh_CN.md +313 -0
- package/dist/index.d.mts +593 -0
- package/dist/index.mjs +1065 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +38 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import crypto, { createCipheriv, createDecipheriv } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
//#region src/api/client.ts
|
|
6
|
+
/**
|
|
7
|
+
* Low-level HTTP API client for the WeChat iLink bot protocol.
|
|
8
|
+
*
|
|
9
|
+
* Endpoints (all POST JSON):
|
|
10
|
+
* - ilink/bot/getupdates — long-poll for new messages
|
|
11
|
+
* - ilink/bot/sendmessage — send a message (text/image/video/file)
|
|
12
|
+
* - ilink/bot/getuploadurl — get CDN upload pre-signed URL
|
|
13
|
+
* - ilink/bot/getconfig — get account config (typing ticket, etc.)
|
|
14
|
+
* - ilink/bot/sendtyping — send/cancel typing indicator
|
|
15
|
+
* - ilink/bot/get_bot_qrcode — initiate QR code login (GET)
|
|
16
|
+
* - ilink/bot/get_qrcode_status — poll QR scan status (GET)
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
19
|
+
const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
20
|
+
/** Default long-poll timeout for getUpdates requests. */
|
|
21
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS$1 = 35e3;
|
|
22
|
+
/** Default timeout for regular API requests. */
|
|
23
|
+
const DEFAULT_API_TIMEOUT_MS = 15e3;
|
|
24
|
+
/** Default timeout for lightweight requests (getConfig, sendTyping). */
|
|
25
|
+
const DEFAULT_CONFIG_TIMEOUT_MS = 1e4;
|
|
26
|
+
/** Default client-side timeout for get_qrcode_status long-poll. */
|
|
27
|
+
const QR_LONG_POLL_TIMEOUT_MS = 35e3;
|
|
28
|
+
/** Default bot_type value. */
|
|
29
|
+
const DEFAULT_BOT_TYPE = "3";
|
|
30
|
+
function ensureTrailingSlash(url) {
|
|
31
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
32
|
+
}
|
|
33
|
+
/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
|
|
34
|
+
function randomWechatUin() {
|
|
35
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
36
|
+
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
37
|
+
}
|
|
38
|
+
var ApiClient = class {
|
|
39
|
+
baseUrl;
|
|
40
|
+
cdnBaseUrl;
|
|
41
|
+
token;
|
|
42
|
+
channelVersion;
|
|
43
|
+
routeTag;
|
|
44
|
+
constructor(opts = {}) {
|
|
45
|
+
this.baseUrl = opts.baseUrl ?? "https://ilinkai.weixin.qq.com";
|
|
46
|
+
this.cdnBaseUrl = opts.cdnBaseUrl ?? "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
47
|
+
this.token = opts.token;
|
|
48
|
+
this.channelVersion = opts.channelVersion ?? "standalone-0.1.0";
|
|
49
|
+
this.routeTag = opts.routeTag;
|
|
50
|
+
}
|
|
51
|
+
/** Update the bearer token (after QR login). */
|
|
52
|
+
setToken(token) {
|
|
53
|
+
this.token = token;
|
|
54
|
+
}
|
|
55
|
+
getToken() {
|
|
56
|
+
return this.token;
|
|
57
|
+
}
|
|
58
|
+
buildBaseInfo() {
|
|
59
|
+
return { channel_version: this.channelVersion };
|
|
60
|
+
}
|
|
61
|
+
buildHeaders(bodyStr) {
|
|
62
|
+
const headers = {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
AuthorizationType: "ilink_bot_token",
|
|
65
|
+
"Content-Length": String(Buffer.byteLength(bodyStr, "utf-8")),
|
|
66
|
+
"X-WECHAT-UIN": randomWechatUin()
|
|
67
|
+
};
|
|
68
|
+
if (this.token?.trim()) headers.Authorization = `Bearer ${this.token.trim()}`;
|
|
69
|
+
if (this.routeTag) headers.SKRouteTag = this.routeTag;
|
|
70
|
+
return headers;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* POST JSON to an iLink API endpoint with timeout + abort.
|
|
74
|
+
*/
|
|
75
|
+
async apiFetch(params) {
|
|
76
|
+
const base = ensureTrailingSlash(this.baseUrl);
|
|
77
|
+
const url = new URL(params.endpoint, base);
|
|
78
|
+
const headers = this.buildHeaders(params.body);
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(url.toString(), {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers,
|
|
85
|
+
body: params.body,
|
|
86
|
+
signal: controller.signal
|
|
87
|
+
});
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
const rawText = await res.text();
|
|
90
|
+
if (!res.ok) throw new Error(`API ${params.endpoint} ${res.status}: ${rawText}`);
|
|
91
|
+
return rawText;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Long-poll for new messages. Returns empty response on client-side timeout
|
|
99
|
+
* (normal for long-poll).
|
|
100
|
+
*/
|
|
101
|
+
async getUpdates(getUpdatesBuf, timeoutMs) {
|
|
102
|
+
const timeout = timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS$1;
|
|
103
|
+
try {
|
|
104
|
+
const rawText = await this.apiFetch({
|
|
105
|
+
endpoint: "ilink/bot/getupdates",
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
get_updates_buf: getUpdatesBuf,
|
|
108
|
+
base_info: this.buildBaseInfo()
|
|
109
|
+
}),
|
|
110
|
+
timeoutMs: timeout
|
|
111
|
+
});
|
|
112
|
+
return JSON.parse(rawText);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err instanceof Error && err.name === "AbortError") return {
|
|
115
|
+
ret: 0,
|
|
116
|
+
msgs: [],
|
|
117
|
+
get_updates_buf: getUpdatesBuf
|
|
118
|
+
};
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Send a message downstream. */
|
|
123
|
+
async sendMessage(req) {
|
|
124
|
+
await this.apiFetch({
|
|
125
|
+
endpoint: "ilink/bot/sendmessage",
|
|
126
|
+
body: JSON.stringify({
|
|
127
|
+
...req,
|
|
128
|
+
base_info: this.buildBaseInfo()
|
|
129
|
+
}),
|
|
130
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** Get a pre-signed CDN upload URL. */
|
|
134
|
+
async getUploadUrl(req) {
|
|
135
|
+
const rawText = await this.apiFetch({
|
|
136
|
+
endpoint: "ilink/bot/getuploadurl",
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
...req,
|
|
139
|
+
base_info: this.buildBaseInfo()
|
|
140
|
+
}),
|
|
141
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS
|
|
142
|
+
});
|
|
143
|
+
return JSON.parse(rawText);
|
|
144
|
+
}
|
|
145
|
+
/** Fetch bot config (includes typing_ticket) for a given user. */
|
|
146
|
+
async getConfig(ilinkUserId, contextToken) {
|
|
147
|
+
const rawText = await this.apiFetch({
|
|
148
|
+
endpoint: "ilink/bot/getconfig",
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
ilink_user_id: ilinkUserId,
|
|
151
|
+
context_token: contextToken,
|
|
152
|
+
base_info: this.buildBaseInfo()
|
|
153
|
+
}),
|
|
154
|
+
timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS
|
|
155
|
+
});
|
|
156
|
+
return JSON.parse(rawText);
|
|
157
|
+
}
|
|
158
|
+
/** Send a typing indicator. */
|
|
159
|
+
async sendTyping(req) {
|
|
160
|
+
await this.apiFetch({
|
|
161
|
+
endpoint: "ilink/bot/sendtyping",
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
...req,
|
|
164
|
+
base_info: this.buildBaseInfo()
|
|
165
|
+
}),
|
|
166
|
+
timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** Fetch a new QR code for bot login. */
|
|
170
|
+
async getQRCode(botType) {
|
|
171
|
+
const base = ensureTrailingSlash(this.baseUrl);
|
|
172
|
+
const bt = botType ?? "3";
|
|
173
|
+
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(bt)}`, base);
|
|
174
|
+
const headers = {};
|
|
175
|
+
if (this.routeTag) headers.SKRouteTag = this.routeTag;
|
|
176
|
+
const res = await fetch(url.toString(), { headers });
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const body = await res.text().catch(() => "(unreadable)");
|
|
179
|
+
throw new Error(`Failed to fetch QR code: ${res.status} ${res.statusText}: ${body}`);
|
|
180
|
+
}
|
|
181
|
+
return await res.json();
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Long-poll the QR code scan status.
|
|
185
|
+
* Returns `{ status: "wait" }` on client-side timeout.
|
|
186
|
+
*/
|
|
187
|
+
async pollQRCodeStatus(qrcode) {
|
|
188
|
+
const base = ensureTrailingSlash(this.baseUrl);
|
|
189
|
+
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
|
|
190
|
+
const headers = { "iLink-App-ClientVersion": "1" };
|
|
191
|
+
if (this.routeTag) headers.SKRouteTag = this.routeTag;
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(url.toString(), {
|
|
196
|
+
headers,
|
|
197
|
+
signal: controller.signal
|
|
198
|
+
});
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
const rawText = await res.text();
|
|
201
|
+
if (!res.ok) throw new Error(`Failed to poll QR status: ${res.status} ${res.statusText}: ${rawText}`);
|
|
202
|
+
return JSON.parse(rawText);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
if (err instanceof Error && err.name === "AbortError") return { status: "wait" };
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/api/types.ts
|
|
212
|
+
/**
|
|
213
|
+
* WeChat iLink bot protocol types.
|
|
214
|
+
*
|
|
215
|
+
* Reverse-engineered from @tencent-weixin/openclaw-weixin.
|
|
216
|
+
* The backend API uses JSON over HTTP; byte fields are base64 strings in JSON.
|
|
217
|
+
*/
|
|
218
|
+
const UploadMediaType = {
|
|
219
|
+
IMAGE: 1,
|
|
220
|
+
VIDEO: 2,
|
|
221
|
+
FILE: 3,
|
|
222
|
+
VOICE: 4
|
|
223
|
+
};
|
|
224
|
+
const MessageType = {
|
|
225
|
+
NONE: 0,
|
|
226
|
+
USER: 1,
|
|
227
|
+
BOT: 2
|
|
228
|
+
};
|
|
229
|
+
const MessageItemType = {
|
|
230
|
+
NONE: 0,
|
|
231
|
+
TEXT: 1,
|
|
232
|
+
IMAGE: 2,
|
|
233
|
+
VOICE: 3,
|
|
234
|
+
FILE: 4,
|
|
235
|
+
VIDEO: 5
|
|
236
|
+
};
|
|
237
|
+
const MessageState = {
|
|
238
|
+
NEW: 0,
|
|
239
|
+
GENERATING: 1,
|
|
240
|
+
FINISH: 2
|
|
241
|
+
};
|
|
242
|
+
const TypingStatus = {
|
|
243
|
+
TYPING: 1,
|
|
244
|
+
CANCEL: 2
|
|
245
|
+
};
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/auth/qr-login.ts
|
|
248
|
+
/**
|
|
249
|
+
* Run the full QR code login flow. Returns a LoginResult.
|
|
250
|
+
*
|
|
251
|
+
* The library does NOT render QR codes — use `opts.onQRCode` to receive
|
|
252
|
+
* the QR code URL and handle display yourself.
|
|
253
|
+
*/
|
|
254
|
+
async function loginWithQRCode(api, opts = {}) {
|
|
255
|
+
const timeoutMs = Math.max(opts.timeoutMs ?? 48e4, 1e3);
|
|
256
|
+
const maxRefreshes = opts.maxRefreshes ?? 3;
|
|
257
|
+
const deadline = Date.now() + timeoutMs;
|
|
258
|
+
let refreshCount = 1;
|
|
259
|
+
const qrResponse = await api.getQRCode(opts.botType);
|
|
260
|
+
let qrcode = qrResponse.qrcode;
|
|
261
|
+
if (opts.onQRCode) await opts.onQRCode(qrResponse.qrcode_img_content);
|
|
262
|
+
while (Date.now() < deadline) {
|
|
263
|
+
if (opts.signal?.aborted) return {
|
|
264
|
+
connected: false,
|
|
265
|
+
message: "Login cancelled."
|
|
266
|
+
};
|
|
267
|
+
const status = await api.pollQRCodeStatus(qrcode);
|
|
268
|
+
opts.onStatus?.(status.status);
|
|
269
|
+
switch (status.status) {
|
|
270
|
+
case "wait": break;
|
|
271
|
+
case "scaned": break;
|
|
272
|
+
case "expired": {
|
|
273
|
+
refreshCount++;
|
|
274
|
+
if (refreshCount > maxRefreshes) return {
|
|
275
|
+
connected: false,
|
|
276
|
+
message: `QR code expired ${maxRefreshes} times. Please restart login.`
|
|
277
|
+
};
|
|
278
|
+
const refreshed = await api.getQRCode(opts.botType);
|
|
279
|
+
qrcode = refreshed.qrcode;
|
|
280
|
+
if (opts.onQRCode) await opts.onQRCode(refreshed.qrcode_img_content);
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "confirmed":
|
|
284
|
+
if (!status.ilink_bot_id) return {
|
|
285
|
+
connected: false,
|
|
286
|
+
message: "Login confirmed but server did not return ilink_bot_id."
|
|
287
|
+
};
|
|
288
|
+
return {
|
|
289
|
+
connected: true,
|
|
290
|
+
botToken: status.bot_token,
|
|
291
|
+
accountId: status.ilink_bot_id,
|
|
292
|
+
baseUrl: status.baseurl,
|
|
293
|
+
userId: status.ilink_user_id,
|
|
294
|
+
message: "Login successful!"
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
connected: false,
|
|
301
|
+
message: "Login timed out."
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/cdn/aes-ecb.ts
|
|
306
|
+
/**
|
|
307
|
+
* AES-128-ECB crypto utilities used by the WeChat CDN for media upload/download.
|
|
308
|
+
*
|
|
309
|
+
* All media files are encrypted with AES-128-ECB (PKCS7 padding) before upload,
|
|
310
|
+
* and must be decrypted after download.
|
|
311
|
+
*/
|
|
312
|
+
/** Encrypt a buffer with AES-128-ECB (PKCS7 padding). */
|
|
313
|
+
function encryptAesEcb(plaintext, key) {
|
|
314
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
315
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
316
|
+
}
|
|
317
|
+
/** Decrypt a buffer with AES-128-ECB (PKCS7 padding). */
|
|
318
|
+
function decryptAesEcb(ciphertext, key) {
|
|
319
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
320
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
321
|
+
}
|
|
322
|
+
/** Compute AES-128-ECB ciphertext size (PKCS7 pads to 16-byte boundary). */
|
|
323
|
+
function aesEcbPaddedSize(plaintextSize) {
|
|
324
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/cdn/cdn-url.ts
|
|
328
|
+
/**
|
|
329
|
+
* CDN URL construction for WeChat CDN upload/download.
|
|
330
|
+
*/
|
|
331
|
+
/** Build a CDN download URL from encrypt_query_param. */
|
|
332
|
+
function buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl) {
|
|
333
|
+
return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
|
|
334
|
+
}
|
|
335
|
+
/** Build a CDN upload URL from upload_param and filekey. */
|
|
336
|
+
function buildCdnUploadUrl(params) {
|
|
337
|
+
return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/cdn/cdn-download.ts
|
|
341
|
+
/**
|
|
342
|
+
* Download and optionally decrypt media from the WeChat CDN.
|
|
343
|
+
*/
|
|
344
|
+
/**
|
|
345
|
+
* Download raw bytes from the CDN (no decryption).
|
|
346
|
+
*/
|
|
347
|
+
async function fetchCdnBytes(url) {
|
|
348
|
+
const res = await fetch(url);
|
|
349
|
+
if (!res.ok) {
|
|
350
|
+
const body = await res.text().catch(() => "(unreadable)");
|
|
351
|
+
throw new Error(`CDN download ${res.status} ${res.statusText}: ${body}`);
|
|
352
|
+
}
|
|
353
|
+
return Buffer.from(await res.arrayBuffer());
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Parse CDNMedia.aes_key into a raw 16-byte AES key.
|
|
357
|
+
*
|
|
358
|
+
* Two encodings are observed:
|
|
359
|
+
* - base64(raw 16 bytes) -> images (aes_key from media field)
|
|
360
|
+
* - base64(hex string of 16 bytes) -> file / voice / video
|
|
361
|
+
*
|
|
362
|
+
* In the second case, base64-decoding yields 32 ASCII hex chars which must
|
|
363
|
+
* then be parsed as hex to recover the actual 16-byte key.
|
|
364
|
+
*/
|
|
365
|
+
function parseAesKey(aesKeyBase64) {
|
|
366
|
+
const decoded = Buffer.from(aesKeyBase64, "base64");
|
|
367
|
+
if (decoded.length === 16) return decoded;
|
|
368
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) return Buffer.from(decoded.toString("ascii"), "hex");
|
|
369
|
+
throw new Error(`aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes`);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
|
|
373
|
+
*/
|
|
374
|
+
async function downloadAndDecrypt(encryptedQueryParam, aesKeyBase64, cdnBaseUrl) {
|
|
375
|
+
const key = parseAesKey(aesKeyBase64);
|
|
376
|
+
return decryptAesEcb(await fetchCdnBytes(buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl)), key);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Download plain (unencrypted) bytes from the CDN.
|
|
380
|
+
*/
|
|
381
|
+
async function downloadPlain(encryptedQueryParam, cdnBaseUrl) {
|
|
382
|
+
return fetchCdnBytes(buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl));
|
|
383
|
+
}
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/media/download.ts
|
|
386
|
+
/**
|
|
387
|
+
* Download and decrypt media from a single MessageItem.
|
|
388
|
+
* Returns null if the item has no downloadable media.
|
|
389
|
+
*/
|
|
390
|
+
async function downloadMediaFromItem(item, cdnBaseUrl) {
|
|
391
|
+
if (item.type === MessageItemType.IMAGE) {
|
|
392
|
+
const img = item.image_item;
|
|
393
|
+
if (!img?.media?.encrypt_query_param) return null;
|
|
394
|
+
const aesKeyBase64 = img.aeskey ? Buffer.from(img.aeskey, "hex").toString("base64") : img.media.aes_key;
|
|
395
|
+
return {
|
|
396
|
+
data: aesKeyBase64 ? await downloadAndDecrypt(img.media.encrypt_query_param, aesKeyBase64, cdnBaseUrl) : await downloadPlain(img.media.encrypt_query_param, cdnBaseUrl),
|
|
397
|
+
kind: "image"
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (item.type === MessageItemType.VOICE) {
|
|
401
|
+
const voice = item.voice_item;
|
|
402
|
+
if (!voice?.media?.encrypt_query_param || !voice.media.aes_key) return null;
|
|
403
|
+
return {
|
|
404
|
+
data: await downloadAndDecrypt(voice.media.encrypt_query_param, voice.media.aes_key, cdnBaseUrl),
|
|
405
|
+
kind: "voice"
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (item.type === MessageItemType.FILE) {
|
|
409
|
+
const fileItem = item.file_item;
|
|
410
|
+
if (!fileItem?.media?.encrypt_query_param || !fileItem.media.aes_key) return null;
|
|
411
|
+
return {
|
|
412
|
+
data: await downloadAndDecrypt(fileItem.media.encrypt_query_param, fileItem.media.aes_key, cdnBaseUrl),
|
|
413
|
+
kind: "file",
|
|
414
|
+
fileName: fileItem.file_name ?? void 0
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (item.type === MessageItemType.VIDEO) {
|
|
418
|
+
const videoItem = item.video_item;
|
|
419
|
+
if (!videoItem?.media?.encrypt_query_param || !videoItem.media.aes_key) return null;
|
|
420
|
+
return {
|
|
421
|
+
data: await downloadAndDecrypt(videoItem.media.encrypt_query_param, videoItem.media.aes_key, cdnBaseUrl),
|
|
422
|
+
kind: "video"
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/util/mime.ts
|
|
429
|
+
const EXTENSION_TO_MIME = {
|
|
430
|
+
".pdf": "application/pdf",
|
|
431
|
+
".doc": "application/msword",
|
|
432
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
433
|
+
".xls": "application/vnd.ms-excel",
|
|
434
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
435
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
436
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
437
|
+
".txt": "text/plain",
|
|
438
|
+
".csv": "text/csv",
|
|
439
|
+
".zip": "application/zip",
|
|
440
|
+
".tar": "application/x-tar",
|
|
441
|
+
".gz": "application/gzip",
|
|
442
|
+
".mp3": "audio/mpeg",
|
|
443
|
+
".ogg": "audio/ogg",
|
|
444
|
+
".wav": "audio/wav",
|
|
445
|
+
".mp4": "video/mp4",
|
|
446
|
+
".mov": "video/quicktime",
|
|
447
|
+
".webm": "video/webm",
|
|
448
|
+
".mkv": "video/x-matroska",
|
|
449
|
+
".avi": "video/x-msvideo",
|
|
450
|
+
".png": "image/png",
|
|
451
|
+
".jpg": "image/jpeg",
|
|
452
|
+
".jpeg": "image/jpeg",
|
|
453
|
+
".gif": "image/gif",
|
|
454
|
+
".webp": "image/webp",
|
|
455
|
+
".bmp": "image/bmp"
|
|
456
|
+
};
|
|
457
|
+
const MIME_TO_EXTENSION = {
|
|
458
|
+
"image/jpeg": ".jpg",
|
|
459
|
+
"image/jpg": ".jpg",
|
|
460
|
+
"image/png": ".png",
|
|
461
|
+
"image/gif": ".gif",
|
|
462
|
+
"image/webp": ".webp",
|
|
463
|
+
"image/bmp": ".bmp",
|
|
464
|
+
"video/mp4": ".mp4",
|
|
465
|
+
"video/quicktime": ".mov",
|
|
466
|
+
"video/webm": ".webm",
|
|
467
|
+
"video/x-matroska": ".mkv",
|
|
468
|
+
"video/x-msvideo": ".avi",
|
|
469
|
+
"audio/mpeg": ".mp3",
|
|
470
|
+
"audio/ogg": ".ogg",
|
|
471
|
+
"audio/wav": ".wav",
|
|
472
|
+
"application/pdf": ".pdf",
|
|
473
|
+
"application/zip": ".zip",
|
|
474
|
+
"application/x-tar": ".tar",
|
|
475
|
+
"application/gzip": ".gz",
|
|
476
|
+
"text/plain": ".txt",
|
|
477
|
+
"text/csv": ".csv"
|
|
478
|
+
};
|
|
479
|
+
/** Get MIME type from filename extension. Defaults to "application/octet-stream". */
|
|
480
|
+
function getMimeFromFilename(filename) {
|
|
481
|
+
return EXTENSION_TO_MIME[path.extname(filename).toLowerCase()] ?? "application/octet-stream";
|
|
482
|
+
}
|
|
483
|
+
/** Get file extension from MIME type. Defaults to ".bin". */
|
|
484
|
+
function getExtensionFromMime(mimeType) {
|
|
485
|
+
return MIME_TO_EXTENSION[mimeType.split(";")[0].trim().toLowerCase()] ?? ".bin";
|
|
486
|
+
}
|
|
487
|
+
/** Get file extension from Content-Type header or URL path. Defaults to ".bin". */
|
|
488
|
+
function getExtensionFromContentTypeOrUrl(contentType, url) {
|
|
489
|
+
if (contentType) {
|
|
490
|
+
const ext = getExtensionFromMime(contentType);
|
|
491
|
+
if (ext !== ".bin") return ext;
|
|
492
|
+
}
|
|
493
|
+
const ext = path.extname(new URL(url).pathname).toLowerCase();
|
|
494
|
+
return new Set(Object.keys(EXTENSION_TO_MIME)).has(ext) ? ext : ".bin";
|
|
495
|
+
}
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/cdn/cdn-upload.ts
|
|
498
|
+
/**
|
|
499
|
+
* Upload encrypted media to the WeChat CDN.
|
|
500
|
+
*/
|
|
501
|
+
const UPLOAD_MAX_RETRIES = 3;
|
|
502
|
+
/**
|
|
503
|
+
* Upload one buffer to the WeChat CDN with AES-128-ECB encryption.
|
|
504
|
+
* Returns the download encrypted_query_param from the CDN `x-encrypted-param` header.
|
|
505
|
+
*/
|
|
506
|
+
async function uploadBufferToCdn(params) {
|
|
507
|
+
const { buf, uploadParam, filekey, cdnBaseUrl, aeskey } = params;
|
|
508
|
+
const ciphertext = encryptAesEcb(buf, aeskey);
|
|
509
|
+
const cdnUrl = buildCdnUploadUrl({
|
|
510
|
+
cdnBaseUrl,
|
|
511
|
+
uploadParam,
|
|
512
|
+
filekey
|
|
513
|
+
});
|
|
514
|
+
let downloadParam;
|
|
515
|
+
let lastError;
|
|
516
|
+
for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) try {
|
|
517
|
+
const res = await fetch(cdnUrl, {
|
|
518
|
+
method: "POST",
|
|
519
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
520
|
+
body: new Uint8Array(ciphertext)
|
|
521
|
+
});
|
|
522
|
+
if (res.status >= 400 && res.status < 500) {
|
|
523
|
+
const errMsg = res.headers.get("x-error-message") ?? await res.text();
|
|
524
|
+
throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
|
|
525
|
+
}
|
|
526
|
+
if (res.status !== 200) {
|
|
527
|
+
const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
|
|
528
|
+
throw new Error(`CDN upload server error: ${errMsg}`);
|
|
529
|
+
}
|
|
530
|
+
downloadParam = res.headers.get("x-encrypted-param") ?? void 0;
|
|
531
|
+
if (!downloadParam) throw new Error("CDN upload response missing x-encrypted-param header");
|
|
532
|
+
break;
|
|
533
|
+
} catch (err) {
|
|
534
|
+
lastError = err;
|
|
535
|
+
if (err instanceof Error && err.message.includes("client error")) throw err;
|
|
536
|
+
if (attempt >= UPLOAD_MAX_RETRIES) break;
|
|
537
|
+
}
|
|
538
|
+
if (!downloadParam) throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
|
|
539
|
+
return { downloadParam };
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/media/upload.ts
|
|
543
|
+
/**
|
|
544
|
+
* High-level media upload pipeline for the WeChat CDN.
|
|
545
|
+
*
|
|
546
|
+
* Flow:
|
|
547
|
+
* 1. Read file -> compute MD5, plaintext size, ciphertext size
|
|
548
|
+
* 2. Generate random 16-byte AES key and filekey
|
|
549
|
+
* 3. Call getUploadUrl to get upload_param
|
|
550
|
+
* 4. Encrypt with AES-128-ECB and POST to CDN
|
|
551
|
+
* 5. Return uploaded file info (download param, key, sizes)
|
|
552
|
+
*/
|
|
553
|
+
async function uploadMedia(params) {
|
|
554
|
+
const { filePath, toUserId, api, cdnBaseUrl, mediaType } = params;
|
|
555
|
+
const plaintext = await fs.readFile(filePath);
|
|
556
|
+
const rawsize = plaintext.length;
|
|
557
|
+
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
|
|
558
|
+
const filesize = aesEcbPaddedSize(rawsize);
|
|
559
|
+
const filekey = crypto.randomBytes(16).toString("hex");
|
|
560
|
+
const aeskey = crypto.randomBytes(16);
|
|
561
|
+
const uploadUrlResp = await api.getUploadUrl({
|
|
562
|
+
filekey,
|
|
563
|
+
media_type: mediaType,
|
|
564
|
+
to_user_id: toUserId,
|
|
565
|
+
rawsize,
|
|
566
|
+
rawfilemd5,
|
|
567
|
+
filesize,
|
|
568
|
+
no_need_thumb: true,
|
|
569
|
+
aeskey: aeskey.toString("hex")
|
|
570
|
+
});
|
|
571
|
+
const uploadParam = uploadUrlResp.upload_param;
|
|
572
|
+
if (!uploadParam) throw new Error(`getUploadUrl returned no upload_param: ${JSON.stringify(uploadUrlResp)}`);
|
|
573
|
+
const { downloadParam } = await uploadBufferToCdn({
|
|
574
|
+
buf: plaintext,
|
|
575
|
+
uploadParam,
|
|
576
|
+
filekey,
|
|
577
|
+
cdnBaseUrl,
|
|
578
|
+
aeskey
|
|
579
|
+
});
|
|
580
|
+
return {
|
|
581
|
+
filekey,
|
|
582
|
+
downloadEncryptedQueryParam: downloadParam,
|
|
583
|
+
aeskey: aeskey.toString("hex"),
|
|
584
|
+
fileSize: rawsize,
|
|
585
|
+
fileSizeCiphertext: filesize
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/** Upload a local image file to the WeChat CDN. */
|
|
589
|
+
async function uploadImage(params) {
|
|
590
|
+
return uploadMedia({
|
|
591
|
+
...params,
|
|
592
|
+
mediaType: UploadMediaType.IMAGE
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/** Upload a local video file to the WeChat CDN. */
|
|
596
|
+
async function uploadVideo(params) {
|
|
597
|
+
return uploadMedia({
|
|
598
|
+
...params,
|
|
599
|
+
mediaType: UploadMediaType.VIDEO
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
/** Upload a local file attachment to the WeChat CDN. */
|
|
603
|
+
async function uploadFile(params) {
|
|
604
|
+
return uploadMedia({
|
|
605
|
+
...params,
|
|
606
|
+
mediaType: UploadMediaType.FILE
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region src/util/random.ts
|
|
611
|
+
/**
|
|
612
|
+
* Generate a prefixed unique ID: `{prefix}:{timestamp}-{8-char hex}`.
|
|
613
|
+
*/
|
|
614
|
+
function generateId(prefix) {
|
|
615
|
+
return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Generate a temporary file name: `{prefix}-{timestamp}-{8-char hex}{ext}`.
|
|
619
|
+
*/
|
|
620
|
+
function tempFileName(prefix, ext) {
|
|
621
|
+
return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
|
|
622
|
+
}
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/media/send.ts
|
|
625
|
+
/**
|
|
626
|
+
* High-level message sending helpers.
|
|
627
|
+
*
|
|
628
|
+
* Builds SendMessageReq payloads for text, image, video, and file messages,
|
|
629
|
+
* and dispatches them through the ApiClient.
|
|
630
|
+
*/
|
|
631
|
+
function generateClientId() {
|
|
632
|
+
return generateId("wechat-ilink");
|
|
633
|
+
}
|
|
634
|
+
function buildReq(params) {
|
|
635
|
+
return { msg: {
|
|
636
|
+
from_user_id: "",
|
|
637
|
+
to_user_id: params.to,
|
|
638
|
+
client_id: generateClientId(),
|
|
639
|
+
message_type: MessageType.BOT,
|
|
640
|
+
message_state: MessageState.FINISH,
|
|
641
|
+
item_list: params.items.length ? params.items : void 0,
|
|
642
|
+
context_token: params.contextToken ?? void 0
|
|
643
|
+
} };
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Send a text message. contextToken is required (echoed from getUpdates).
|
|
647
|
+
*/
|
|
648
|
+
async function sendText(api, to, text, contextToken) {
|
|
649
|
+
const clientId = generateClientId();
|
|
650
|
+
const req = { msg: {
|
|
651
|
+
from_user_id: "",
|
|
652
|
+
to_user_id: to,
|
|
653
|
+
client_id: clientId,
|
|
654
|
+
message_type: MessageType.BOT,
|
|
655
|
+
message_state: MessageState.FINISH,
|
|
656
|
+
item_list: text ? [{
|
|
657
|
+
type: MessageItemType.TEXT,
|
|
658
|
+
text_item: { text }
|
|
659
|
+
}] : void 0,
|
|
660
|
+
context_token: contextToken
|
|
661
|
+
} };
|
|
662
|
+
await api.sendMessage(req);
|
|
663
|
+
return clientId;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Send an image message with a previously uploaded file.
|
|
667
|
+
*/
|
|
668
|
+
async function sendImage(api, to, uploaded, contextToken, caption) {
|
|
669
|
+
const items = [];
|
|
670
|
+
if (caption) items.push({
|
|
671
|
+
type: MessageItemType.TEXT,
|
|
672
|
+
text_item: { text: caption }
|
|
673
|
+
});
|
|
674
|
+
items.push({
|
|
675
|
+
type: MessageItemType.IMAGE,
|
|
676
|
+
image_item: {
|
|
677
|
+
media: {
|
|
678
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
679
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
680
|
+
encrypt_type: 1
|
|
681
|
+
},
|
|
682
|
+
mid_size: uploaded.fileSizeCiphertext
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
let lastClientId = "";
|
|
686
|
+
for (const item of items) {
|
|
687
|
+
const req = buildReq({
|
|
688
|
+
to,
|
|
689
|
+
contextToken,
|
|
690
|
+
items: [item]
|
|
691
|
+
});
|
|
692
|
+
lastClientId = req.msg?.client_id ?? lastClientId;
|
|
693
|
+
await api.sendMessage(req);
|
|
694
|
+
}
|
|
695
|
+
return lastClientId;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Send a video message with a previously uploaded file.
|
|
699
|
+
*/
|
|
700
|
+
async function sendVideo(api, to, uploaded, contextToken, caption) {
|
|
701
|
+
const items = [];
|
|
702
|
+
if (caption) items.push({
|
|
703
|
+
type: MessageItemType.TEXT,
|
|
704
|
+
text_item: { text: caption }
|
|
705
|
+
});
|
|
706
|
+
items.push({
|
|
707
|
+
type: MessageItemType.VIDEO,
|
|
708
|
+
video_item: {
|
|
709
|
+
media: {
|
|
710
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
711
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
712
|
+
encrypt_type: 1
|
|
713
|
+
},
|
|
714
|
+
video_size: uploaded.fileSizeCiphertext
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
let lastClientId = "";
|
|
718
|
+
for (const item of items) {
|
|
719
|
+
const req = buildReq({
|
|
720
|
+
to,
|
|
721
|
+
contextToken,
|
|
722
|
+
items: [item]
|
|
723
|
+
});
|
|
724
|
+
lastClientId = req.msg?.client_id ?? lastClientId;
|
|
725
|
+
await api.sendMessage(req);
|
|
726
|
+
}
|
|
727
|
+
return lastClientId;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Send a file attachment with a previously uploaded file.
|
|
731
|
+
*/
|
|
732
|
+
async function sendFileMessage(api, to, fileName, uploaded, contextToken, caption) {
|
|
733
|
+
const items = [];
|
|
734
|
+
if (caption) items.push({
|
|
735
|
+
type: MessageItemType.TEXT,
|
|
736
|
+
text_item: { text: caption }
|
|
737
|
+
});
|
|
738
|
+
items.push({
|
|
739
|
+
type: MessageItemType.FILE,
|
|
740
|
+
file_item: {
|
|
741
|
+
media: {
|
|
742
|
+
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
|
|
743
|
+
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
|
|
744
|
+
encrypt_type: 1
|
|
745
|
+
},
|
|
746
|
+
file_name: fileName,
|
|
747
|
+
len: String(uploaded.fileSize)
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
let lastClientId = "";
|
|
751
|
+
for (const item of items) {
|
|
752
|
+
const req = buildReq({
|
|
753
|
+
to,
|
|
754
|
+
contextToken,
|
|
755
|
+
items: [item]
|
|
756
|
+
});
|
|
757
|
+
lastClientId = req.msg?.client_id ?? lastClientId;
|
|
758
|
+
await api.sendMessage(req);
|
|
759
|
+
}
|
|
760
|
+
return lastClientId;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Upload and send a local file as a media message. Routing by MIME type:
|
|
764
|
+
* - video/* -> video message
|
|
765
|
+
* - image/* -> image message
|
|
766
|
+
* - else -> file attachment
|
|
767
|
+
*/
|
|
768
|
+
async function sendMediaFile(api, to, filePath, contextToken, caption) {
|
|
769
|
+
const mime = getMimeFromFilename(filePath);
|
|
770
|
+
const cdnBaseUrl = api.cdnBaseUrl;
|
|
771
|
+
if (mime.startsWith("video/")) return sendVideo(api, to, await uploadVideo({
|
|
772
|
+
filePath,
|
|
773
|
+
toUserId: to,
|
|
774
|
+
api,
|
|
775
|
+
cdnBaseUrl
|
|
776
|
+
}), contextToken, caption);
|
|
777
|
+
if (mime.startsWith("image/")) return sendImage(api, to, await uploadImage({
|
|
778
|
+
filePath,
|
|
779
|
+
toUserId: to,
|
|
780
|
+
api,
|
|
781
|
+
cdnBaseUrl
|
|
782
|
+
}), contextToken, caption);
|
|
783
|
+
return sendFileMessage(api, to, path.basename(filePath), await uploadFile({
|
|
784
|
+
filePath,
|
|
785
|
+
toUserId: to,
|
|
786
|
+
api,
|
|
787
|
+
cdnBaseUrl
|
|
788
|
+
}), contextToken, caption);
|
|
789
|
+
}
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/monitor.ts
|
|
792
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
|
|
793
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
794
|
+
const BACKOFF_DELAY_MS = 3e4;
|
|
795
|
+
const RETRY_DELAY_MS = 2e3;
|
|
796
|
+
/** Error code returned by the server when the bot session has expired. */
|
|
797
|
+
const SESSION_EXPIRED_ERRCODE = -14;
|
|
798
|
+
function sleep(ms, signal) {
|
|
799
|
+
return new Promise((resolve, reject) => {
|
|
800
|
+
const t = setTimeout(resolve, ms);
|
|
801
|
+
signal?.addEventListener("abort", () => {
|
|
802
|
+
clearTimeout(t);
|
|
803
|
+
reject(/* @__PURE__ */ new Error("aborted"));
|
|
804
|
+
}, { once: true });
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Start the long-poll monitor loop. Runs until the AbortSignal fires.
|
|
809
|
+
*/
|
|
810
|
+
async function startMonitor(api, opts, callbacks) {
|
|
811
|
+
const { signal } = opts;
|
|
812
|
+
let getUpdatesBuf = "";
|
|
813
|
+
if (opts.loadSyncBuf) {
|
|
814
|
+
const loaded = await opts.loadSyncBuf();
|
|
815
|
+
if (loaded) getUpdatesBuf = loaded;
|
|
816
|
+
}
|
|
817
|
+
let nextTimeoutMs = opts.longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
818
|
+
let consecutiveFailures = 0;
|
|
819
|
+
while (!signal?.aborted) try {
|
|
820
|
+
const resp = await api.getUpdates(getUpdatesBuf, nextTimeoutMs);
|
|
821
|
+
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) nextTimeoutMs = resp.longpolling_timeout_ms;
|
|
822
|
+
if (resp.ret !== void 0 && resp.ret !== 0 || resp.errcode !== void 0 && resp.errcode !== 0) {
|
|
823
|
+
if (resp.errcode === -14 || resp.ret === -14) {
|
|
824
|
+
callbacks.onSessionExpired?.();
|
|
825
|
+
await sleep(3600 * 1e3, signal);
|
|
826
|
+
consecutiveFailures = 0;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
consecutiveFailures++;
|
|
830
|
+
callbacks.onError?.(/* @__PURE__ */ new Error(`getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""}`));
|
|
831
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
832
|
+
consecutiveFailures = 0;
|
|
833
|
+
await sleep(BACKOFF_DELAY_MS, signal);
|
|
834
|
+
} else await sleep(RETRY_DELAY_MS, signal);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
consecutiveFailures = 0;
|
|
838
|
+
callbacks.onPoll?.(resp);
|
|
839
|
+
if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
|
|
840
|
+
getUpdatesBuf = resp.get_updates_buf;
|
|
841
|
+
if (opts.saveSyncBuf) await opts.saveSyncBuf(getUpdatesBuf);
|
|
842
|
+
}
|
|
843
|
+
const msgs = resp.msgs ?? [];
|
|
844
|
+
for (const msg of msgs) await callbacks.onMessage(msg);
|
|
845
|
+
} catch (err) {
|
|
846
|
+
if (signal?.aborted) return;
|
|
847
|
+
consecutiveFailures++;
|
|
848
|
+
callbacks.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
849
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
850
|
+
consecutiveFailures = 0;
|
|
851
|
+
await sleep(BACKOFF_DELAY_MS, signal);
|
|
852
|
+
} else await sleep(RETRY_DELAY_MS, signal);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
//#endregion
|
|
856
|
+
//#region src/client.ts
|
|
857
|
+
/**
|
|
858
|
+
* WeChatClient — high-level client for the WeChat iLink bot protocol.
|
|
859
|
+
*
|
|
860
|
+
* Wraps the low-level API, QR login, long-poll monitor, media upload/download,
|
|
861
|
+
* and message sending into a single EventEmitter-based interface.
|
|
862
|
+
*
|
|
863
|
+
* This is a pure in-memory client. It does NOT persist any data to disk.
|
|
864
|
+
* The caller is responsible for:
|
|
865
|
+
* - Storing/loading the token and accountId across restarts
|
|
866
|
+
* - Storing/loading the sync buf (long-poll cursor) for message resume
|
|
867
|
+
* - Rendering QR codes during login
|
|
868
|
+
*
|
|
869
|
+
* Usage:
|
|
870
|
+
* const client = new WeChatClient({ token, accountId });
|
|
871
|
+
* client.on("message", (msg) => { ... });
|
|
872
|
+
* await client.start();
|
|
873
|
+
*/
|
|
874
|
+
/** Extract text body from a message's item_list. */
|
|
875
|
+
function extractTextBody(itemList) {
|
|
876
|
+
if (!itemList?.length) return "";
|
|
877
|
+
for (const item of itemList) {
|
|
878
|
+
if (item.type === MessageItemType.TEXT && item.text_item?.text != null) return String(item.text_item.text);
|
|
879
|
+
if (item.type === MessageItemType.VOICE && item.voice_item?.text) return item.voice_item.text;
|
|
880
|
+
}
|
|
881
|
+
return "";
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Normalize a raw account ID (e.g. "hex@im.bot") to a safe key
|
|
885
|
+
* (e.g. "hex-im-bot").
|
|
886
|
+
*/
|
|
887
|
+
function normalizeAccountId(raw) {
|
|
888
|
+
return raw.trim().toLowerCase().replace(/[@.]/g, "-");
|
|
889
|
+
}
|
|
890
|
+
var WeChatClient = class extends EventEmitter {
|
|
891
|
+
api;
|
|
892
|
+
accountId;
|
|
893
|
+
abortController;
|
|
894
|
+
/** In-process cache: userId -> contextToken (echoed from getUpdates). */
|
|
895
|
+
contextTokens = /* @__PURE__ */ new Map();
|
|
896
|
+
constructor(opts = {}) {
|
|
897
|
+
super();
|
|
898
|
+
this.api = new ApiClient(opts);
|
|
899
|
+
this.accountId = opts.accountId;
|
|
900
|
+
}
|
|
901
|
+
getAccountId() {
|
|
902
|
+
return this.accountId;
|
|
903
|
+
}
|
|
904
|
+
/** Get the cached context token for a user (needed for sending replies). */
|
|
905
|
+
getContextToken(userId) {
|
|
906
|
+
return this.contextTokens.get(userId);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Run the QR code login flow. On success, configures the API client
|
|
910
|
+
* with the new token and sets the accountId.
|
|
911
|
+
*
|
|
912
|
+
* The library does NOT render QR codes. Use `opts.onQRCode` to receive
|
|
913
|
+
* the QR code URL and handle display yourself.
|
|
914
|
+
*
|
|
915
|
+
* The library does NOT persist credentials. The caller should save
|
|
916
|
+
* `result.botToken`, `result.accountId`, and `result.baseUrl` themselves.
|
|
917
|
+
*/
|
|
918
|
+
async login(opts = {}) {
|
|
919
|
+
const result = await loginWithQRCode(this.api, opts);
|
|
920
|
+
if (result.connected && result.botToken && result.accountId) {
|
|
921
|
+
this.accountId = normalizeAccountId(result.accountId);
|
|
922
|
+
this.api.setToken(result.botToken);
|
|
923
|
+
}
|
|
924
|
+
return result;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Start the long-poll monitor loop. Emits "message" events for each
|
|
928
|
+
* inbound message.
|
|
929
|
+
*
|
|
930
|
+
* Sync buf persistence is opt-in via `opts.loadSyncBuf` / `opts.saveSyncBuf`.
|
|
931
|
+
*
|
|
932
|
+
* Call `stop()` to terminate.
|
|
933
|
+
*/
|
|
934
|
+
async start(opts = {}) {
|
|
935
|
+
if (!this.accountId) throw new Error("No accountId set. Call login() first or pass accountId in constructor.");
|
|
936
|
+
if (!this.api.getToken()) throw new Error("No token set. Call login() first or pass token in constructor options.");
|
|
937
|
+
this.abortController = new AbortController();
|
|
938
|
+
const monitorOpts = {
|
|
939
|
+
signal: this.abortController.signal,
|
|
940
|
+
...opts
|
|
941
|
+
};
|
|
942
|
+
await startMonitor(this.api, monitorOpts, {
|
|
943
|
+
onMessage: async (msg) => {
|
|
944
|
+
if (msg.context_token && msg.from_user_id) this.contextTokens.set(msg.from_user_id, msg.context_token);
|
|
945
|
+
this.emit("message", msg);
|
|
946
|
+
},
|
|
947
|
+
onError: (err) => {
|
|
948
|
+
this.emit("error", err);
|
|
949
|
+
},
|
|
950
|
+
onSessionExpired: () => {
|
|
951
|
+
this.emit("sessionExpired");
|
|
952
|
+
},
|
|
953
|
+
onPoll: (resp) => {
|
|
954
|
+
this.emit("poll", resp);
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
/** Stop the long-poll monitor loop. */
|
|
959
|
+
stop() {
|
|
960
|
+
this.abortController?.abort();
|
|
961
|
+
this.abortController = void 0;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Send a text message. Uses the cached context token for the target user.
|
|
965
|
+
* Pass an explicit contextToken to override.
|
|
966
|
+
*/
|
|
967
|
+
async sendText(to, text, contextToken) {
|
|
968
|
+
const ct = contextToken ?? this.contextTokens.get(to);
|
|
969
|
+
if (!ct) throw new Error(`No context_token for user ${to}. Receive a message from them first.`);
|
|
970
|
+
return sendText(this.api, to, text, ct);
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Upload a local file and send it as the appropriate media type.
|
|
974
|
+
* (image/*, video/*, or file attachment based on MIME type.)
|
|
975
|
+
*/
|
|
976
|
+
async sendMedia(to, filePath, caption, contextToken) {
|
|
977
|
+
const ct = contextToken ?? this.contextTokens.get(to);
|
|
978
|
+
if (!ct) throw new Error(`No context_token for user ${to}. Receive a message from them first.`);
|
|
979
|
+
return sendMediaFile(this.api, to, filePath, ct, caption);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Send an already-uploaded image.
|
|
983
|
+
*/
|
|
984
|
+
async sendUploadedImage(to, uploaded, caption, contextToken) {
|
|
985
|
+
const ct = contextToken ?? this.contextTokens.get(to);
|
|
986
|
+
if (!ct) throw new Error(`No context_token for user ${to}.`);
|
|
987
|
+
return sendImage(this.api, to, uploaded, ct, caption);
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Send an already-uploaded video.
|
|
991
|
+
*/
|
|
992
|
+
async sendUploadedVideo(to, uploaded, caption, contextToken) {
|
|
993
|
+
const ct = contextToken ?? this.contextTokens.get(to);
|
|
994
|
+
if (!ct) throw new Error(`No context_token for user ${to}.`);
|
|
995
|
+
return sendVideo(this.api, to, uploaded, ct, caption);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Send an already-uploaded file attachment.
|
|
999
|
+
*/
|
|
1000
|
+
async sendUploadedFile(to, fileName, uploaded, caption, contextToken) {
|
|
1001
|
+
const ct = contextToken ?? this.contextTokens.get(to);
|
|
1002
|
+
if (!ct) throw new Error(`No context_token for user ${to}.`);
|
|
1003
|
+
return sendFileMessage(this.api, to, fileName, uploaded, ct, caption);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Send a "typing" indicator to the user. Requires a typing_ticket
|
|
1007
|
+
* (obtained from getConfig).
|
|
1008
|
+
*/
|
|
1009
|
+
async sendTyping(userId, typingTicket, status = "typing") {
|
|
1010
|
+
const req = {
|
|
1011
|
+
ilink_user_id: userId,
|
|
1012
|
+
typing_ticket: typingTicket,
|
|
1013
|
+
status: status === "typing" ? TypingStatus.TYPING : TypingStatus.CANCEL
|
|
1014
|
+
};
|
|
1015
|
+
await this.api.sendTyping(req);
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Get the typing ticket for a user (calls getConfig).
|
|
1019
|
+
*/
|
|
1020
|
+
async getTypingTicket(userId, contextToken) {
|
|
1021
|
+
return (await this.api.getConfig(userId, contextToken)).typing_ticket ?? "";
|
|
1022
|
+
}
|
|
1023
|
+
async uploadImage(filePath, toUserId) {
|
|
1024
|
+
return uploadImage({
|
|
1025
|
+
filePath,
|
|
1026
|
+
toUserId,
|
|
1027
|
+
api: this.api,
|
|
1028
|
+
cdnBaseUrl: this.api.cdnBaseUrl
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
async uploadVideo(filePath, toUserId) {
|
|
1032
|
+
return uploadVideo({
|
|
1033
|
+
filePath,
|
|
1034
|
+
toUserId,
|
|
1035
|
+
api: this.api,
|
|
1036
|
+
cdnBaseUrl: this.api.cdnBaseUrl
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
async uploadFile(filePath, toUserId) {
|
|
1040
|
+
return uploadFile({
|
|
1041
|
+
filePath,
|
|
1042
|
+
toUserId,
|
|
1043
|
+
api: this.api,
|
|
1044
|
+
cdnBaseUrl: this.api.cdnBaseUrl
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Download and decrypt a media item from an inbound message.
|
|
1049
|
+
*/
|
|
1050
|
+
async downloadMedia(item) {
|
|
1051
|
+
return downloadMediaFromItem(item, this.api.cdnBaseUrl);
|
|
1052
|
+
}
|
|
1053
|
+
/** Extract the text body from a WeixinMessage. */
|
|
1054
|
+
static extractText(msg) {
|
|
1055
|
+
return extractTextBody(msg.item_list);
|
|
1056
|
+
}
|
|
1057
|
+
/** Check if a message item is a media type. */
|
|
1058
|
+
static isMediaItem(item) {
|
|
1059
|
+
return item.type === MessageItemType.IMAGE || item.type === MessageItemType.VIDEO || item.type === MessageItemType.FILE || item.type === MessageItemType.VOICE;
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
//#endregion
|
|
1063
|
+
export { ApiClient, CDN_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BOT_TYPE, MessageItemType, MessageState, MessageType, SESSION_EXPIRED_ERRCODE, TypingStatus, UploadMediaType, WeChatClient, aesEcbPaddedSize, buildCdnDownloadUrl, buildCdnUploadUrl, decryptAesEcb, downloadAndDecrypt, downloadMediaFromItem, downloadPlain, encryptAesEcb, generateId, getExtensionFromContentTypeOrUrl, getExtensionFromMime, getMimeFromFilename, loginWithQRCode, normalizeAccountId, parseAesKey, sendFileMessage, sendImage, sendMediaFile, sendText, sendVideo, startMonitor, tempFileName, uploadBufferToCdn, uploadFile, uploadImage, uploadVideo };
|
|
1064
|
+
|
|
1065
|
+
//# sourceMappingURL=index.mjs.map
|