@linxiraos/pi-channels 1.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/src/wechat.ts ADDED
@@ -0,0 +1,570 @@
1
+ /**
2
+ * WeChat ClawBot channel — Tencent iLink Bot API over plain fetch.
3
+ *
4
+ * Protocol reference: `temp/weixin-clawbot/weixin-bot-api.md` (iLink
5
+ * 1.0.2/2.x), endpoints on `https://ilinkai.weixin.qq.com`.
6
+ *
7
+ * - Login: `get_bot_qrcode` (bot_type=3) → poll `get_qrcode_status` until
8
+ * `status === "confirmed"` → persist `bot_token`/`baseurl` back into the
9
+ * web config. `expired` → re-fetch a fresh QR code.
10
+ * - Inbound: `getupdates` long poll (server holds ≤35s); the returned
11
+ * `get_updates_buf` cursor MUST be sent back on the next request or the
12
+ * server redelivers. Only `message_type === 1` messages with a text item
13
+ * are forwarded.
14
+ * - Outbound: `sendmessage` carries `context_token` from the peer's latest
15
+ * inbound message — without it the reply never lands in the right chat.
16
+ * - Auth: `Authorization: Bearer <bot_token>` + a fresh random
17
+ * `X-WECHAT-UIN` per request. A 401 triggers the QR re-login flow.
18
+ * - Images: AES-128-ECB-encrypted CDN upload via `getuploadurl`, then a
19
+ * `sendmessage` image item referencing the uploaded URL. The exact
20
+ * upload-url response keys are best-effort (undocumented); failures throw
21
+ * so callers can fall back to `sendText`.
22
+ */
23
+
24
+ import * as crypto from "node:crypto";
25
+ import { logger } from "@linxiraos/pi-utils";
26
+ import type { ChatChannel, ChatImage } from "./channel";
27
+ import type { ChannelsWebConfig } from "./types";
28
+
29
+ export type WeChatInboundHandler = (peer: string, body: string, messageId?: string) => void;
30
+
31
+ /** QR login progress surfaced to the UI (web settings panel). */
32
+ export interface WeChatQrStatus {
33
+ qrcode: string;
34
+ /** `qrcode_img_content` (image data URL) when the server provides it, else the raw qrcode. */
35
+ qrcodeUrl: string;
36
+ /** Latest poll status: "wait" | "scaned" | "confirmed" | "expired" | raw server status. */
37
+ status: string;
38
+ }
39
+
40
+ export interface WeChatChannelOptions {
41
+ config: {
42
+ botToken?: string;
43
+ ilinkBotId?: string;
44
+ ilinkUserId?: string;
45
+ baseUrl?: string;
46
+ /** New `/api/v1/wechat` API host (defaults to the shared ilink host). */
47
+ endpoint?: string;
48
+ /** Persisted peer → context_token bindings restored on start. */
49
+ peerTokens?: Record<string, string>;
50
+ };
51
+ webConfig?: ChannelsWebConfig;
52
+ onMessage: WeChatInboundHandler;
53
+ /** Surfaced QR-login progress (the web-ui renders the QR for the user to scan). */
54
+ onQrCode?: (payload: WeChatQrStatus) => void;
55
+ /** Test seam: inject a custom fetch implementation (defaults to global fetch). */
56
+ customFetch?: typeof globalThis.fetch;
57
+ }
58
+
59
+ const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
60
+ const CHANNEL_VERSION = "2.4.3";
61
+ const QR_POLL_INTERVAL_MS = 2_000;
62
+ const RETRY_DELAY_MS = 3_000;
63
+
64
+ function randomUin(): string {
65
+ const value = crypto.getRandomValues(new Uint32Array(1))[0];
66
+ return Buffer.from(String(value), "utf8").toString("base64");
67
+ }
68
+
69
+ function randomClientId(): string {
70
+ return `zeta-wechat-${crypto.randomBytes(4).toString("hex")}`;
71
+ }
72
+
73
+ /** AES-128-ECB with PKCS7 padding (WebCrypto has no ECB mode; CBC + zero IV is equivalent). */
74
+ async function aesEcbEncrypt(data: Uint8Array, key: Uint8Array): Promise<Uint8Array> {
75
+ const paddedLength = data.length + (16 - (data.length % 16));
76
+ const padded = new Uint8Array(paddedLength);
77
+ padded.set(data);
78
+ for (let i = data.length; i < paddedLength; i++) {
79
+ padded[i] = paddedLength - data.length;
80
+ }
81
+ const cryptoKey = await crypto.subtle.importKey("raw", new Uint8Array(key), { name: "AES-CBC" }, false, ["encrypt"]);
82
+ const encrypted = await crypto.subtle.encrypt({ name: "AES-CBC", iv: new Uint8Array(16) }, cryptoKey, padded);
83
+ return new Uint8Array(encrypted);
84
+ }
85
+
86
+ interface WeChatMessage {
87
+ from_user_id?: string;
88
+ to_user_id?: string;
89
+ message_type?: number;
90
+ message_state?: number;
91
+ context_token?: string;
92
+ item_list?: Array<{ type?: number; text_item?: { text?: string } }>;
93
+ }
94
+
95
+ export class WeChatChannel implements ChatChannel {
96
+ readonly id = "wechat" as const;
97
+ readonly #options: WeChatChannelOptions;
98
+ readonly #onMessage: WeChatInboundHandler;
99
+ readonly #fetch: typeof globalThis.fetch;
100
+ #botToken: string | undefined;
101
+ #baseUrl: string;
102
+ /** Latest context_token per peer — required to reply in the right chat. */
103
+ #contextTokens = new Map<string, string>();
104
+ #started = false;
105
+ #abort: AbortController | null = null;
106
+ #loop: Promise<void> | null = null;
107
+
108
+ constructor(options: WeChatChannelOptions) {
109
+ this.#options = options;
110
+ this.#onMessage = options.onMessage;
111
+ this.#botToken = options.config.botToken;
112
+ this.#baseUrl = options.config.endpoint ?? options.config.baseUrl ?? DEFAULT_BASE_URL;
113
+ this.#fetch = options.customFetch ?? globalThis.fetch;
114
+ // Restore persisted peer bindings so replies keep landing after a restart.
115
+ for (const [peer, token] of Object.entries(options.config.peerTokens ?? {})) {
116
+ if (token) this.#contextTokens.set(peer, token);
117
+ }
118
+ }
119
+
120
+ /** Restart the login/message loop (e.g. user re-triggers QR login from the UI). */
121
+ async reconnect(): Promise<void> {
122
+ if (!this.#started) {
123
+ this.#started = true;
124
+ this.#abort = new AbortController();
125
+ this.#loop = this.#run();
126
+ return;
127
+ }
128
+ const previous = this.#loop;
129
+ this.#abort?.abort();
130
+ this.#abort = new AbortController();
131
+ this.#loop = this.#run();
132
+ if (previous) void previous.catch(() => {});
133
+ }
134
+
135
+ async start(): Promise<void> {
136
+ if (this.#started) return;
137
+ this.#started = true;
138
+ this.#abort = new AbortController();
139
+ this.#loop = this.#run();
140
+ }
141
+
142
+ async stop(): Promise<void> {
143
+ if (!this.#started) return;
144
+ this.#started = false;
145
+ this.#abort?.abort();
146
+ await this.#loop?.catch(() => {});
147
+ this.#loop = null;
148
+ }
149
+
150
+ async #run(): Promise<void> {
151
+ if (!this.#botToken) {
152
+ await this.#loginFlow();
153
+ }
154
+ await this.#pollLoop();
155
+ }
156
+
157
+ #headers(): Record<string, string> {
158
+ const headers: Record<string, string> = {
159
+ "Content-Type": "application/json",
160
+ AuthorizationType: "ilink_bot_token",
161
+ "X-WECHAT-UIN": randomUin(),
162
+ "iLink-App-Id": "bot",
163
+ "iLink-App-ClientVersion": String((2 << 16) | (4 << 8) | 3),
164
+ };
165
+ if (this.#botToken) headers.Authorization = `Bearer ${this.#botToken}`;
166
+ return headers;
167
+ }
168
+
169
+ #baseInfo(): Record<string, string> {
170
+ return { channel_version: CHANNEL_VERSION, bot_agent: "zeta-WeChat-ClawBot/1.0.0" };
171
+ }
172
+
173
+ async #apiGet(path: string): Promise<Record<string, unknown>> {
174
+ const res = await this.#fetch(`${this.#baseUrl}/${path}`, {
175
+ headers: this.#headers(),
176
+ signal: this.#abort?.signal,
177
+ });
178
+ const text = await res.text();
179
+ if (res.status === 401) throw new WeChatAuthError("iLink request unauthorized");
180
+ try {
181
+ return JSON.parse(text) as Record<string, unknown>;
182
+ } catch {
183
+ throw new Error(`iLink returned non-JSON (HTTP ${res.status})`);
184
+ }
185
+ }
186
+
187
+ async #apiPost(path: string, body: unknown): Promise<Record<string, unknown>> {
188
+ const res = await this.#fetch(`${this.#baseUrl}/${path}`, {
189
+ method: "POST",
190
+ headers: this.#headers(),
191
+ body: JSON.stringify(body),
192
+ signal: this.#abort?.signal,
193
+ });
194
+ const text = await res.text();
195
+ if (res.status === 401) throw new WeChatAuthError("iLink request unauthorized");
196
+ try {
197
+ return JSON.parse(text) as Record<string, unknown>;
198
+ } catch {
199
+ throw new Error(`iLink returned non-JSON (HTTP ${res.status})`);
200
+ }
201
+ }
202
+
203
+ async #loginFlow(): Promise<void> {
204
+ // Prefer the newer `/api/v1/wechat` endpoints; fall back to the legacy
205
+ // iLink `get_bot_qrcode`/`get_qrcode_status` flow when the host does not
206
+ // expose them (endpoint probing keeps older hosts working).
207
+ try {
208
+ await this.#loginFlowV1();
209
+ return;
210
+ } catch (error) {
211
+ logger.warn("WeChat v1 login unavailable; falling back to legacy iLink flow", {
212
+ error: error instanceof Error ? error.message : String(error),
213
+ });
214
+ }
215
+ await this.#loginFlowLegacy();
216
+ }
217
+
218
+ async #loginFlowV1(): Promise<void> {
219
+ // QR fetch: POST /api/v1/wechat/qrcode (no args). Official response nests
220
+ // `qrcode_url` + `qrcode` (the status token) under `data`; accept the
221
+ // flat `qrcode_url`/`token` shape too for hosts that don't nest.
222
+ const qr = await this.#apiPost("api/v1/wechat/qrcode", {});
223
+ const qrData = (qr.data as Record<string, unknown> | undefined) ?? qr;
224
+ const qrcodeUrl = typeof qrData.qrcode_url === "string" ? qrData.qrcode_url : "";
225
+ const token =
226
+ typeof qrData.qrcode === "string" && qrData.qrcode !== ""
227
+ ? qrData.qrcode
228
+ : typeof qr.token === "string"
229
+ ? qr.token
230
+ : "";
231
+ if (qrcodeUrl === "" || token === "") {
232
+ throw new Error("WeChat v1 login failed: no qrcode_url/token returned");
233
+ }
234
+ this.#options.onQrCode?.({ qrcode: token, qrcodeUrl, status: "wait" });
235
+ logger.info("WeChat channel: scan the QR code to log in", { qrcodeUrl });
236
+
237
+ while (this.#started && !this.#abort?.signal.aborted) {
238
+ try {
239
+ const result = await this.#apiPost("api/v1/wechat/qrcode/status", { qrcode: token });
240
+ const body = (result.data as Record<string, unknown> | undefined) ?? result;
241
+ const status = typeof body.status === "string" ? body.status : "";
242
+ if (status === "confirmed") {
243
+ const credentials =
244
+ (body.credentials as Record<string, unknown> | undefined) ??
245
+ (result.credentials as Record<string, unknown> | undefined) ??
246
+ {};
247
+ const botToken = credentials.bot_token;
248
+ const ilinkBotId = credentials.ilink_bot_id;
249
+ const ilinkUserId = credentials.ilink_user_id;
250
+ if (typeof botToken !== "string" || botToken === "") {
251
+ throw new Error("WeChat v1 login confirmed without bot_token");
252
+ }
253
+ this.#botToken = botToken;
254
+ // The response may carry a host override for the message API.
255
+ const baseUrl = typeof body.baseurl === "string" && body.baseurl !== "" ? body.baseurl : "";
256
+ if (baseUrl !== "" && baseUrl !== this.#baseUrl) {
257
+ this.#baseUrl = baseUrl;
258
+ }
259
+ // Restore persisted peer bindings so replies keep landing
260
+ // in the right chats after a restart.
261
+ for (const [peer, contextToken] of Object.entries(this.#options.config.peerTokens ?? {})) {
262
+ if (contextToken) this.#contextTokens.set(peer, contextToken);
263
+ }
264
+ const config = this.#options.webConfig;
265
+ if (config) {
266
+ await config.set("channels.wechat.botToken", this.#botToken);
267
+ if (baseUrl !== "") {
268
+ await config.set("channels.wechat.baseUrl", baseUrl);
269
+ }
270
+ if (typeof ilinkBotId === "string" && ilinkBotId !== "") {
271
+ await config.set("channels.wechat.ilinkBotId", ilinkBotId);
272
+ }
273
+ if (typeof ilinkUserId === "string" && ilinkUserId !== "") {
274
+ await config.set("channels.wechat.ilinkUserId", ilinkUserId);
275
+ }
276
+ }
277
+ this.#options.onQrCode?.({ qrcode: token, qrcodeUrl, status: "confirmed" });
278
+ logger.info("WeChat channel logged in (v1 API)", { baseUrl: this.#baseUrl });
279
+ return;
280
+ }
281
+ if (status === "expired") {
282
+ logger.warn("WeChat QR code expired; fetching a fresh one");
283
+ this.#options.onQrCode?.({ qrcode: token, qrcodeUrl, status: "expired" });
284
+ return await this.#loginFlowV1();
285
+ }
286
+ this.#options.onQrCode?.({
287
+ qrcode: token,
288
+ qrcodeUrl,
289
+ status: status === "scaned" ? "scaned" : status === "" ? "wait" : status,
290
+ });
291
+ await Bun.sleep(QR_POLL_INTERVAL_MS);
292
+ } catch (error) {
293
+ logger.warn("WeChat v1 QR status poll failed", {
294
+ error: error instanceof Error ? error.message : String(error),
295
+ });
296
+ await Bun.sleep(QR_POLL_INTERVAL_MS);
297
+ }
298
+ }
299
+ throw new Error("WeChat login aborted");
300
+ }
301
+
302
+ async #loginFlowLegacy(): Promise<void> {
303
+ // QR fetch: 2.x POST first, fall back to the 1.0.2 GET shape.
304
+ let data: Record<string, unknown> | null = null;
305
+ try {
306
+ data = await this.#apiPost("ilink/bot/get_bot_qrcode?bot_type=3", {
307
+ local_token_list: this.#botToken ? [this.#botToken] : [],
308
+ });
309
+ } catch {
310
+ data = null;
311
+ }
312
+ if (!data?.qrcode) {
313
+ data = await this.#apiGet("ilink/bot/get_bot_qrcode?bot_type=3");
314
+ }
315
+ const qrcode = data?.qrcode;
316
+ if (typeof qrcode !== "string" || qrcode === "") {
317
+ throw new Error("WeChat login failed: no QR code returned");
318
+ }
319
+ const qrcodeUrl = String(data?.qrcode_img_content ?? qrcode);
320
+ this.#options.onQrCode?.({ qrcode, qrcodeUrl, status: "wait" });
321
+ logger.info("WeChat channel: scan the QR code to log in", { qrcodeUrl });
322
+
323
+ while (this.#started && !this.#abort?.signal.aborted) {
324
+ try {
325
+ const status = await this.#apiGet(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`);
326
+ if (typeof status.bot_token === "string" && status.bot_token !== "") {
327
+ this.#botToken = status.bot_token;
328
+ if (typeof status.baseurl === "string" && status.baseurl !== "") {
329
+ this.#baseUrl = status.baseurl;
330
+ }
331
+ // Persist credentials so the next serve start skips scanning.
332
+ const config = this.#options.webConfig;
333
+ if (config) {
334
+ await config.set("channels.wechat.botToken", this.#botToken);
335
+ if (this.#baseUrl !== DEFAULT_BASE_URL) {
336
+ await config.set("channels.wechat.baseUrl", this.#baseUrl);
337
+ }
338
+ if (typeof status.ilink_bot_id === "string" && status.ilink_bot_id !== "") {
339
+ await config.set("channels.wechat.ilinkBotId", status.ilink_bot_id);
340
+ }
341
+ if (typeof status.ilink_user_id === "string" && status.ilink_user_id !== "") {
342
+ await config.set("channels.wechat.ilinkUserId", status.ilink_user_id);
343
+ }
344
+ }
345
+ this.#options.onQrCode?.({ qrcode, qrcodeUrl, status: "confirmed" });
346
+ logger.info("WeChat channel logged in", { baseUrl: this.#baseUrl });
347
+ return;
348
+ }
349
+ if (status.status === "scaned_but_redirect") {
350
+ // Server redirected the session to another host; follow it.
351
+ if (typeof status.redirect_host === "string" && status.redirect_host !== "") {
352
+ this.#baseUrl = `https://${status.redirect_host}`;
353
+ }
354
+ this.#options.onQrCode?.({ qrcode, qrcodeUrl, status: "scaned" });
355
+ } else if (status.status === "expired") {
356
+ logger.warn("WeChat QR code expired; fetching a fresh one");
357
+ this.#options.onQrCode?.({ qrcode, qrcodeUrl, status: "expired" });
358
+ return await this.#loginFlow();
359
+ } else {
360
+ this.#options.onQrCode?.({
361
+ qrcode,
362
+ qrcodeUrl,
363
+ status: typeof status.status === "string" && status.status !== "" ? status.status : "wait",
364
+ });
365
+ }
366
+ await Bun.sleep(QR_POLL_INTERVAL_MS);
367
+ } catch (error) {
368
+ logger.warn("WeChat QR status poll failed", {
369
+ error: error instanceof Error ? error.message : String(error),
370
+ });
371
+ await Bun.sleep(QR_POLL_INTERVAL_MS);
372
+ }
373
+ }
374
+ throw new Error("WeChat login aborted");
375
+ }
376
+
377
+ async #pollLoop(): Promise<void> {
378
+ let cursor = "";
379
+ while (this.#started && !this.#abort?.signal.aborted) {
380
+ try {
381
+ const data = await this.#apiPost("ilink/bot/getupdates", {
382
+ get_updates_buf: cursor,
383
+ base_info: this.#baseInfo(),
384
+ });
385
+ const nextCursor = data.get_updates_buf;
386
+ if (typeof nextCursor === "string" && nextCursor !== "") {
387
+ cursor = nextCursor;
388
+ }
389
+ for (const raw of (data.msgs as unknown[] | undefined) ?? []) {
390
+ const msg = raw as WeChatMessage;
391
+ if (msg.message_type !== 1) continue;
392
+ const from = msg.from_user_id;
393
+ if (typeof from !== "string" || from === "") continue;
394
+ if (
395
+ typeof msg.context_token === "string" &&
396
+ msg.context_token !== "" &&
397
+ msg.context_token !== this.#contextTokens.get(from)
398
+ ) {
399
+ this.#contextTokens.set(from, msg.context_token);
400
+ void this.#persistPeerTokens().catch(error => {
401
+ logger.warn("WeChat peer-token persistence failed", {
402
+ error: error instanceof Error ? error.message : String(error),
403
+ });
404
+ });
405
+ }
406
+ const textItem = (msg.item_list ?? []).find(item => item.type === 1)?.text_item?.text;
407
+ if (typeof textItem !== "string" || textItem === "") continue;
408
+ logger.debug("WeChat message received", { from, length: textItem.length });
409
+ this.#onMessage(from, textItem, String(msg.to_user_id ?? ""));
410
+ }
411
+ } catch (error) {
412
+ if (this.#abort?.signal.aborted) break;
413
+ if (error instanceof WeChatAuthError) {
414
+ logger.warn("WeChat credentials invalid; restarting QR login");
415
+ try {
416
+ await this.#loginFlow();
417
+ continue;
418
+ } catch (loginError) {
419
+ logger.error("WeChat re-login failed", {
420
+ error: loginError instanceof Error ? loginError.message : String(loginError),
421
+ });
422
+ await Bun.sleep(RETRY_DELAY_MS);
423
+ }
424
+ continue;
425
+ }
426
+ logger.warn("WeChat getupdates failed", {
427
+ error: error instanceof Error ? error.message : String(error),
428
+ });
429
+ await Bun.sleep(RETRY_DELAY_MS);
430
+ }
431
+ }
432
+ }
433
+
434
+ #contextTokenFor(peer: string): string | undefined {
435
+ return this.#contextTokens.get(peer);
436
+ }
437
+
438
+ /** Persist the peer → context_token map so bindings survive restarts. */
439
+ async #persistPeerTokens(): Promise<void> {
440
+ const config = this.#options.webConfig;
441
+ if (!config) return;
442
+ const snapshot: Record<string, string> = {};
443
+ for (const [peer, token] of this.#contextTokens) {
444
+ if (token) snapshot[peer] = token;
445
+ }
446
+ await config.set("channels.wechat.peerTokens", snapshot);
447
+ }
448
+
449
+ /**
450
+ * Unbind the bound peer(s) from this bot: ask the host to reset the channel
451
+ * and clear the persisted credentials + peer bindings so the next start
452
+ * requires a fresh QR scan.
453
+ */
454
+ async unbind(): Promise<void> {
455
+ try {
456
+ await this.#apiPost("api/v1/wechat/channel_reset", {});
457
+ } catch (error) {
458
+ logger.warn("WeChat channel_reset failed", {
459
+ error: error instanceof Error ? error.message : String(error),
460
+ });
461
+ }
462
+ this.#contextTokens.clear();
463
+ this.#botToken = undefined;
464
+ const config = this.#options.webConfig;
465
+ if (config) {
466
+ await config.set("channels.wechat.peerTokens", {});
467
+ await config.set("channels.wechat.botToken", "");
468
+ await config.set("channels.wechat.ilinkBotId", "");
469
+ await config.set("channels.wechat.ilinkUserId", "");
470
+ }
471
+ }
472
+
473
+ async sendText(to: string, text: string): Promise<void> {
474
+ const contextToken = this.#contextTokenFor(to);
475
+ if (!contextToken) {
476
+ throw new Error("WeChat: no context token for peer (wait for an inbound message first)");
477
+ }
478
+ const data = await this.#apiPost("ilink/bot/sendmessage", {
479
+ msg: {
480
+ from_user_id: "",
481
+ to_user_id: to,
482
+ client_id: randomClientId(),
483
+ message_type: 2,
484
+ message_state: 2,
485
+ context_token: contextToken,
486
+ item_list: [{ type: 1, text_item: { text } }],
487
+ },
488
+ base_info: this.#baseInfo(),
489
+ });
490
+ if (data?.ret !== undefined && data.ret !== 0) {
491
+ throw new Error(`WeChat sendmessage failed: ret=${String(data.ret)}`);
492
+ }
493
+ }
494
+
495
+ async sendImage(to: string, image: ChatImage, caption?: string): Promise<void> {
496
+ const contextToken = this.#contextTokenFor(to);
497
+ if (!contextToken) {
498
+ throw new Error("WeChat: no context token for peer (wait for an inbound message first)");
499
+ }
500
+ const aesKey = crypto.getRandomValues(new Uint8Array(16));
501
+ const encrypted = await aesEcbEncrypt(image.data, aesKey);
502
+ const fileBase = (caption ?? "plan").replace(/[^\p{L}\p{N}]+/gu, "-").slice(0, 40) || "plan";
503
+
504
+ // 1. Request a presigned CDN upload URL.
505
+ const upload = await this.#apiPost("ilink/bot/getuploadurl", {
506
+ msg: {
507
+ from_user_id: "",
508
+ to_user_id: to,
509
+ client_id: randomClientId(),
510
+ message_type: 2,
511
+ message_state: 2,
512
+ context_token: contextToken,
513
+ item_list: [
514
+ {
515
+ type: 2,
516
+ image_item: {
517
+ aes_key: Buffer.from(aesKey).toString("base64"),
518
+ file_size: encrypted.length,
519
+ file_name: `${fileBase}.png`,
520
+ },
521
+ },
522
+ ],
523
+ },
524
+ base_info: this.#baseInfo(),
525
+ });
526
+ const uploadUrl =
527
+ typeof upload.upload_full_url === "string"
528
+ ? upload.upload_full_url
529
+ : typeof upload.full_url === "string"
530
+ ? upload.full_url
531
+ : undefined;
532
+ if (!uploadUrl) {
533
+ throw new Error("WeChat getuploadurl returned no upload URL; cannot send image");
534
+ }
535
+
536
+ // 2. PUT the encrypted payload to the CDN.
537
+ const putRes = await this.#fetch(uploadUrl, { method: "PUT", body: encrypted, signal: this.#abort?.signal });
538
+ if (!putRes.ok) {
539
+ throw new Error(`WeChat CDN upload failed (HTTP ${putRes.status})`);
540
+ }
541
+
542
+ // 3. Reference the uploaded media in a sendmessage.
543
+ const data = await this.#apiPost("ilink/bot/sendmessage", {
544
+ msg: {
545
+ from_user_id: "",
546
+ to_user_id: to,
547
+ client_id: randomClientId(),
548
+ message_type: 2,
549
+ message_state: 2,
550
+ context_token: contextToken,
551
+ item_list: [
552
+ {
553
+ type: 2,
554
+ image_item: {
555
+ aes_key: Buffer.from(aesKey).toString("base64"),
556
+ full_url: uploadUrl,
557
+ file_name: `${fileBase}.png`,
558
+ },
559
+ },
560
+ ],
561
+ },
562
+ base_info: this.#baseInfo(),
563
+ });
564
+ if (data?.ret !== undefined && data.ret !== 0) {
565
+ throw new Error(`WeChat sendmessage (image) failed: ret=${String(data.ret)}`);
566
+ }
567
+ }
568
+ }
569
+
570
+ class WeChatAuthError extends Error {}