/** * WeCom Agent API 客户端 * 管理 AccessToken 缓存和 API 调用 */ import crypto from "node:crypto"; import { API_ENDPOINTS, LIMITS } from "../types/constants.js"; import type { ResolvedAgentAccount } from "../types/index.js"; import { readResponseBodyAsBuffer, wecomFetch } from "../http.js"; import { resolveWecomEgressProxyUrlFromNetwork } from "../config/index.js"; /** * **TokenCache (AccessToken 缓存结构)** * * 用于缓存企业微信 API 调用所需的 AccessToken。 * @property token 缓存的 Token 字符串 * @property expiresAt 过期时间戳 (ms) * @property refreshPromise 当前正在进行的刷新 Promise (防止并发刷新) */ type TokenCache = { token: string; expiresAt: number; refreshPromise: Promise | null; }; const tokenCaches = new Map(); function normalizeUploadFilename(filename: string): string { const trimmed = filename.trim(); if (!trimmed) return "file.bin"; const ext = trimmed.includes(".") ? `.${trimmed.split(".").pop()!.toLowerCase()}` : ""; const base = ext ? trimmed.slice(0, -ext.length) : trimmed; const sanitizedBase = base .replace(/[^\x20-\x7e]/g, "_") .replace(/["\\\/;=]/g, "_") .replace(/\s+/g, "_") .replace(/_+/g, "_") .replace(/^_+|_+$/g, ""); const safeBase = sanitizedBase || "file"; const safeExt = ext.replace(/[^a-z0-9.]/g, ""); return `${safeBase}${safeExt || ".bin"}`; } function guessUploadContentType(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase() || ""; const contentTypeMap: Record = { // image jpg: "image/jpg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", // audio / video amr: "voice/amr", mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", ogg: "audio/ogg", mp4: "video/mp4", mov: "video/quicktime", // documents txt: "text/plain", md: "text/markdown", csv: "text/csv", tsv: "text/tab-separated-values", json: "application/json", xml: "application/xml", yaml: "application/yaml", yml: "application/yaml", pdf: "application/pdf", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text", // archives zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed", gz: "application/gzip", tgz: "application/gzip", tar: "application/x-tar", }; return contentTypeMap[ext] || "application/octet-stream"; } function requireAgentId(agent: ResolvedAgentAccount): number { if (typeof agent.agentId === "number" && Number.isFinite(agent.agentId)) return agent.agentId; throw new Error(`wecom agent account=${agent.accountId} missing agentId; sending via cgi-bin/message/send requires agentId`); } /** * **getAccessToken (获取 AccessToken)** * * 获取企业微信 API 调用所需的 AccessToken。 * 具备自动缓存和过期刷新机制。 * * @param agent Agent 账号信息 * @returns 有效的 AccessToken */ export async function getAccessToken(agent: ResolvedAgentAccount): Promise { const cacheKey = `${agent.corpId}:${String(agent.agentId ?? "na")}`; let cache = tokenCaches.get(cacheKey); if (!cache) { cache = { token: "", expiresAt: 0, refreshPromise: null }; tokenCaches.set(cacheKey, cache); } const now = Date.now(); if (cache.token && cache.expiresAt > now + LIMITS.TOKEN_REFRESH_BUFFER_MS) { return cache.token; } // 防止并发刷新 if (cache.refreshPromise) { return cache.refreshPromise; } cache.refreshPromise = (async () => { try { const url = `${API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`; const res = await wecomFetch(url, undefined, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS }); const json = await res.json() as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }; if (!json?.access_token) { throw new Error(`gettoken failed: ${json?.errcode} ${json?.errmsg}`); } cache!.token = json.access_token; cache!.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000; return cache!.token; } finally { cache!.refreshPromise = null; } })(); return cache.refreshPromise; } /** * **sendText (发送文本消息)** * * 调用 `message/send` (Agent) 或 `appchat/send` (群聊) 发送文本。 * * @param params.agent 发送方 Agent * @param params.toUser 接收用户 ID (单聊可选,可与 toParty/toTag 同时使用) * @param params.toParty 接收部门 ID (单聊可选) * @param params.toTag 接收标签 ID (单聊可选) * @param params.chatId 接收群 ID (群聊模式必填,互斥) * @param params.text 消息内容 */ export async function sendText(params: { agent: ResolvedAgentAccount; toUser?: string; toParty?: string; toTag?: string; chatId?: string; text: string; }): Promise { const { agent, toUser, toParty, toTag, chatId, text } = params; const token = await getAccessToken(agent); const useChat = Boolean(chatId); const url = useChat ? `${API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}` : `${API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`; const body = useChat ? { chatid: chatId, msgtype: "text", text: { content: text } } : { touser: toUser, toparty: toParty, totag: toTag, msgtype: "text", agentid: requireAgentId(agent), text: { content: text } }; const res = await wecomFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS }); const json = await res.json() as { errcode?: number; errmsg?: string; invaliduser?: string; invalidparty?: string; invalidtag?: string; }; if (json?.errcode !== 0) { throw new Error(`send failed: ${json?.errcode} ${json?.errmsg}`); } if (json?.invaliduser || json?.invalidparty || json?.invalidtag) { const details = [ json.invaliduser ? `invaliduser=${json.invaliduser}` : "", json.invalidparty ? `invalidparty=${json.invalidparty}` : "", json.invalidtag ? `invalidtag=${json.invalidtag}` : "" ].filter(Boolean).join(", "); throw new Error(`send partial failure: ${details}`); } } /** * **uploadMedia (上传媒体文件)** * * 上传临时素材到企业微信。 * 素材有效期为 3 天。 * * @param params.type 媒体类型 (image, voice, video, file) * @param params.buffer 文件二进制数据 * @param params.filename 文件名 (需包含正确扩展名) * @returns 媒体 ID (media_id) */ export async function uploadMedia(params: { agent: ResolvedAgentAccount; type: "image" | "voice" | "video" | "file"; buffer: Buffer; filename: string; }): Promise { const { agent, type, buffer, filename } = params; const safeFilename = normalizeUploadFilename(filename); const token = await getAccessToken(agent); const proxyUrl = resolveWecomEgressProxyUrlFromNetwork(agent.network); // 添加 debug=1 参数获取更多错误信息 const url = `${API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}&debug=1`; // DEBUG: 输出上传信息 console.log(`[wecom-upload] Uploading media: type=${type}, filename=${safeFilename}, size=${buffer.length} bytes`); const uploadOnce = async (fileContentType: string) => { // 手动构造 multipart/form-data 请求体 // 企业微信要求包含 filename 和 filelength const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`; const header = Buffer.from( `--${boundary}\r\n` + `Content-Disposition: form-data; name="media"; filename="${safeFilename}"; filelength=${buffer.length}\r\n` + `Content-Type: ${fileContentType}\r\n\r\n` ); const footer = Buffer.from(`\r\n--${boundary}--\r\n`); const body = Buffer.concat([header, buffer, footer]); console.log(`[wecom-upload] Multipart body size=${body.length}, boundary=${boundary}, fileContentType=${fileContentType}`); const res = await wecomFetch(url, { method: "POST", headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": String(body.length), }, body: body, }, { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS }); const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string }; console.log(`[wecom-upload] Response:`, JSON.stringify(json)); return json; }; const preferredContentType = guessUploadContentType(safeFilename); let json = await uploadOnce(preferredContentType); // 某些文件类型在严格网关/企业微信校验下可能失败,回退到通用类型再试一次。 if (!json?.media_id && preferredContentType !== "application/octet-stream") { console.warn( `[wecom-upload] Upload failed with ${preferredContentType}, retrying as application/octet-stream: ${json?.errcode} ${json?.errmsg}`, ); json = await uploadOnce("application/octet-stream"); } if (!json?.media_id) { throw new Error(`upload failed: ${json?.errcode} ${json?.errmsg}`); } return json.media_id; } /** * **sendMedia (发送媒体消息)** * * 发送图片、音频、视频或文件。需先通过 `uploadMedia` 获取 media_id。 * * @param params.agent 发送方 Agent * @param params.toUser 接收用户 ID (单聊可选) * @param params.toParty 接收部门 ID (单聊可选) * @param params.toTag 接收标签 ID (单聊可选) * @param params.chatId 接收群 ID (群聊模式必填) * @param params.mediaId 媒体 ID * @param params.mediaType 媒体类型 * @param params.title 视频标题 (可选) * @param params.description 视频描述 (可选) */ export async function sendMedia(params: { agent: ResolvedAgentAccount; toUser?: string; toParty?: string; toTag?: string; chatId?: string; mediaId: string; mediaType: "image" | "voice" | "video" | "file"; title?: string; description?: string; }): Promise { const { agent, toUser, toParty, toTag, chatId, mediaId, mediaType, title, description } = params; const token = await getAccessToken(agent); const useChat = Boolean(chatId); const url = useChat ? `${API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}` : `${API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`; const mediaPayload = mediaType === "video" ? { media_id: mediaId, title: title ?? "Video", description: description ?? "" } : { media_id: mediaId }; const body = useChat ? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload } : { touser: toUser, toparty: toParty, totag: toTag, msgtype: mediaType, agentid: requireAgentId(agent), [mediaType]: mediaPayload }; const res = await wecomFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS }); const json = await res.json() as { errcode?: number; errmsg?: string; invaliduser?: string; invalidparty?: string; invalidtag?: string; }; if (json?.errcode !== 0) { throw new Error(`send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`); } if (json?.invaliduser || json?.invalidparty || json?.invalidtag) { const details = [ json.invaliduser ? `invaliduser=${json.invaliduser}` : "", json.invalidparty ? `invalidparty=${json.invalidparty}` : "", json.invalidtag ? `invalidtag=${json.invalidtag}` : "" ].filter(Boolean).join(", "); throw new Error(`send ${mediaType} partial failure: ${details}`); } } /** * **downloadMedia (下载媒体文件)** * * 通过 media_id 从企业微信服务器下载临时素材。 * * @returns { buffer, contentType } */ export async function downloadMedia(params: { agent: ResolvedAgentAccount; mediaId: string; maxBytes?: number; }): Promise<{ buffer: Buffer; contentType: string; filename?: string }> { const { agent, mediaId } = params; const token = await getAccessToken(agent); const url = `${API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`; const res = await wecomFetch(url, undefined, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS }); if (!res.ok) { throw new Error(`download failed: ${res.status}`); } const contentType = res.headers.get("content-type") || "application/octet-stream"; const disposition = res.headers.get("content-disposition") || ""; const filename = (() => { // 兼容:filename="a.md" / filename=a.md / filename*=UTF-8''a%2Eb.md const mStar = disposition.match(/filename\*\s*=\s*([^;]+)/i); if (mStar) { const raw = mStar[1]!.trim().replace(/^"(.*)"$/, "$1"); const parts = raw.split("''"); const encoded = parts.length === 2 ? parts[1]! : raw; try { return decodeURIComponent(encoded); } catch { return encoded; } } const m = disposition.match(/filename\s*=\s*([^;]+)/i); if (!m) return undefined; return m[1]!.trim().replace(/^"(.*)"$/, "$1") || undefined; })(); // 检查是否返回了错误 JSON if (contentType.includes("application/json")) { const json = await res.json() as { errcode?: number; errmsg?: string }; throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`); } const buffer = await readResponseBodyAsBuffer(res, params.maxBytes); return { buffer, contentType, filename }; }