241 lines
7.5 KiB
TypeScript
241 lines
7.5 KiB
TypeScript
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<string, string> {
|
|
const headers: Record<string, string> = {
|
|
"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<string> {
|
|
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<GetUpdatesResp> {
|
|
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<GetUploadUrlResp> {
|
|
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<void> {
|
|
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<GetConfigResp> {
|
|
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<void> {
|
|
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",
|
|
});
|
|
}
|