import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { loadConfigRouteTag } from "../auth/accounts.js"; import { logger } from "../util/logger.js"; import { redactBody, redactUrl } from "../util/redact.js"; import type { BaseInfo, GetUploadUrlReq, GetUploadUrlResp, GetUpdatesReq, GetUpdatesResp, SendMessageReq, SendTypingReq, GetConfigResp, } from "./types.js"; export type WeixinApiOptions = { baseUrl: string; token?: string; timeoutMs?: number; /** Long-poll timeout for getUpdates (server may hold the request up to this). */ longPollTimeoutMs?: number; }; // --------------------------------------------------------------------------- // BaseInfo — attached to every outgoing CGI request // --------------------------------------------------------------------------- function readChannelVersion(): string { try { const dir = path.dirname(fileURLToPath(import.meta.url)); const pkgPath = path.resolve(dir, "..", "..", "package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string }; return pkg.version ?? "unknown"; } catch { return "unknown"; } } const CHANNEL_VERSION = readChannelVersion(); /** Build the `base_info` payload included in every API request. */ export function buildBaseInfo(): BaseInfo { return { channel_version: CHANNEL_VERSION }; } /** Default timeout for long-poll getUpdates requests. */ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; /** Default timeout for regular API requests (sendMessage, getUploadUrl). */ const DEFAULT_API_TIMEOUT_MS = 15_000; /** Default timeout for lightweight API requests (getConfig, sendTyping). */ const DEFAULT_CONFIG_TIMEOUT_MS = 10_000; function ensureTrailingSlash(url: string): string { return url.endsWith("/") ? url : `${url}/`; } /** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */ function randomWechatUin(): string { const uint32 = crypto.randomBytes(4).readUInt32BE(0); return Buffer.from(String(uint32), "utf-8").toString("base64"); } function buildHeaders(opts: { token?: string; body: string }): Record { const headers: Record = { "Content-Type": "application/json", AuthorizationType: "ilink_bot_token", "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")), "X-WECHAT-UIN": randomWechatUin(), }; if (opts.token?.trim()) { headers.Authorization = `Bearer ${opts.token.trim()}`; } const routeTag = loadConfigRouteTag(); if (routeTag) { headers.SKRouteTag = routeTag; } logger.debug( `requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`, ); return headers; } /** * Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort. * Returns the raw response text on success; throws on HTTP error or timeout. */ async function apiFetch(params: { baseUrl: string; endpoint: string; body: string; token?: string; timeoutMs: number; label: string; }): Promise { const base = ensureTrailingSlash(params.baseUrl); const url = new URL(params.endpoint, base); const hdrs = buildHeaders({ token: params.token, body: params.body }); logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`); const controller = new AbortController(); const t = setTimeout(() => controller.abort(), params.timeoutMs); try { const res = await fetch(url.toString(), { method: "POST", headers: hdrs, body: params.body, signal: controller.signal, }); clearTimeout(t); const rawText = await res.text(); logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`); if (!res.ok) { throw new Error(`${params.label} ${res.status}: ${rawText}`); } return rawText; } catch (err) { clearTimeout(t); throw err; } } /** * Long-poll getUpdates. Server should hold the request until new messages or timeout. * * On client-side timeout (no server response within timeoutMs), returns an empty response * with ret=0 so the caller can simply retry. This is normal for long-poll. */ export async function getUpdates( params: GetUpdatesReq & { baseUrl: string; token?: string; timeoutMs?: number; }, ): Promise { const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS; try { const rawText = await apiFetch({ baseUrl: params.baseUrl, endpoint: "ilink/bot/getupdates", body: JSON.stringify({ get_updates_buf: params.get_updates_buf ?? "", base_info: buildBaseInfo(), }), token: params.token, timeoutMs: timeout, label: "getUpdates", }); const resp: GetUpdatesResp = JSON.parse(rawText); return resp; } catch (err) { // Long-poll timeout is normal; return empty response so caller can retry if (err instanceof Error && err.name === "AbortError") { logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`); return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf }; } throw err; } } /** Get a pre-signed CDN upload URL for a file. */ export async function getUploadUrl( params: GetUploadUrlReq & WeixinApiOptions, ): Promise { const rawText = await apiFetch({ baseUrl: params.baseUrl, endpoint: "ilink/bot/getuploadurl", body: JSON.stringify({ filekey: params.filekey, media_type: params.media_type, to_user_id: params.to_user_id, rawsize: params.rawsize, rawfilemd5: params.rawfilemd5, filesize: params.filesize, thumb_rawsize: params.thumb_rawsize, thumb_rawfilemd5: params.thumb_rawfilemd5, thumb_filesize: params.thumb_filesize, no_need_thumb: params.no_need_thumb, aeskey: params.aeskey, base_info: buildBaseInfo(), }), token: params.token, timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS, label: "getUploadUrl", }); const resp: GetUploadUrlResp = JSON.parse(rawText); return resp; } /** Send a single message downstream. */ export async function sendMessage( params: WeixinApiOptions & { body: SendMessageReq }, ): Promise { await apiFetch({ baseUrl: params.baseUrl, endpoint: "ilink/bot/sendmessage", body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }), token: params.token, timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS, label: "sendMessage", }); } /** Fetch bot config (includes typing_ticket) for a given user. */ export async function getConfig( params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string }, ): Promise { const rawText = await apiFetch({ baseUrl: params.baseUrl, endpoint: "ilink/bot/getconfig", body: JSON.stringify({ ilink_user_id: params.ilinkUserId, context_token: params.contextToken, base_info: buildBaseInfo(), }), token: params.token, timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS, label: "getConfig", }); const resp: GetConfigResp = JSON.parse(rawText); return resp; } /** Send a typing indicator to a user. */ export async function sendTyping( params: WeixinApiOptions & { body: SendTypingReq }, ): Promise { await apiFetch({ baseUrl: params.baseUrl, endpoint: "ilink/bot/sendtyping", body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }), token: params.token, timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS, label: "sendTyping", }); }