"use strict";Object.defineProperty(exports, "__esModule", { value: true });exports.downloadMedia = downloadMedia;exports.getAccessToken = getAccessToken;exports.sendMedia = sendMedia;exports.sendText = sendText;exports.uploadMedia = uploadMedia; var _nodeCrypto = _interopRequireDefault(require("node:crypto")); var _constants = require("../types/constants.js"); var _http = require("../http.js"); var _index = require("../config/index.js");function _interopRequireDefault(e) {return e && e.__esModule ? e : { default: e };} /** * WeCom Agent API 客户端 * 管理 AccessToken 缓存和 API 调用 */ /** * **TokenCache (AccessToken 缓存结构)** * * 用于缓存企业微信 API 调用所需的 AccessToken。 * @property token 缓存的 Token 字符串 * @property expiresAt 过期时间戳 (ms) * @property refreshPromise 当前正在进行的刷新 Promise (防止并发刷新) */ const tokenCaches = new Map(); function normalizeUploadFilename(filename) { 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) { const ext = filename.split(".").pop()?.toLowerCase() || ""; const contentTypeMap = { // 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) { 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 */ async function getAccessToken(agent) { 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 + _constants.LIMITS.TOKEN_REFRESH_BUFFER_MS) { return cache.token; } // 防止并发刷新 if (cache.refreshPromise) { return cache.refreshPromise; } cache.refreshPromise = (async () => { try { const url = `${_constants.API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`; const res = await (0, _http.wecomFetch)(url, undefined, { proxyUrl: (0, _index.resolveWecomEgressProxyUrlFromNetwork)(agent.network), timeoutMs: _constants.LIMITS.REQUEST_TIMEOUT_MS }); const json = await res.json(); 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 消息内容 */ async function sendText(params) { const { agent, toUser, toParty, toTag, chatId, text } = params; const token = await getAccessToken(agent); const useChat = Boolean(chatId); const url = useChat ? `${_constants.API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}` : `${_constants.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 (0, _http.wecomFetch)(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, { proxyUrl: (0, _index.resolveWecomEgressProxyUrlFromNetwork)(agent.network), timeoutMs: _constants.LIMITS.REQUEST_TIMEOUT_MS }); const json = await res.json(); 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) */ async function uploadMedia(params) { const { agent, type, buffer, filename } = params; const safeFilename = normalizeUploadFilename(filename); const token = await getAccessToken(agent); const proxyUrl = (0, _index.resolveWecomEgressProxyUrlFromNetwork)(agent.network); // 添加 debug=1 参数获取更多错误信息 const url = `${_constants.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) => { // 手动构造 multipart/form-data 请求体 // 企业微信要求包含 filename 和 filelength const boundary = `----WebKitFormBoundary${_nodeCrypto.default.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 (0, _http.wecomFetch)(url, { method: "POST", headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": String(body.length) }, body: body }, { proxyUrl, timeoutMs: _constants.LIMITS.REQUEST_TIMEOUT_MS }); const json = await res.json(); 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 视频描述 (可选) */ async function sendMedia(params) { const { agent, toUser, toParty, toTag, chatId, mediaId, mediaType, title, description } = params; const token = await getAccessToken(agent); const useChat = Boolean(chatId); const url = useChat ? `${_constants.API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}` : `${_constants.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 (0, _http.wecomFetch)(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, { proxyUrl: (0, _index.resolveWecomEgressProxyUrlFromNetwork)(agent.network), timeoutMs: _constants.LIMITS.REQUEST_TIMEOUT_MS }); const json = await res.json(); 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 } */ async function downloadMedia(params) { const { agent, mediaId } = params; const token = await getAccessToken(agent); const url = `${_constants.API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`; const res = await (0, _http.wecomFetch)(url, undefined, { proxyUrl: (0, _index.resolveWecomEgressProxyUrlFromNetwork)(agent.network), timeoutMs: _constants.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(); throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`); } const buffer = await (0, _http.readResponseBodyAsBuffer)(res, params.maxBytes); return { buffer, contentType, filename }; } /* v9-c7ac440ab55289fe */