/**
* 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<string> | null;
};
const tokenCaches = new Map<string, TokenCache>();
/**
* **getAccessToken (获取 AccessToken)**
*
* 获取企业微信 API 调用所需的 AccessToken。
* 具备自动缓存和过期刷新机制。
*
* @param agent Agent 账号信息
* @returns 有效的 AccessToken
*/
export async function getAccessToken(agent: ResolvedAgentAccount): Promise<string> {
const cacheKey = `${agent.corpId}:${agent.agentId}`;
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<void> {
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: agent.agentId,
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<string> {
const { agent, type, buffer, filename } = params;
const token = await getAccessToken(agent);
// 添加 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=${filename}, size=${buffer.length} bytes`);
// 手动构造 multipart/form-data 请求体
// 企业微信要求包含 filename 和 filelength
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
// 根据文件类型设置 Content-Type
const contentTypeMap: Record<string, string> = {
jpg: "image/jpg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
bmp: "image/bmp", amr: "voice/amr", mp4: "video/mp4",
};
const ext = filename.split(".").pop()?.toLowerCase() || "";
const fileContentType = contentTypeMap[ext] || "application/octet-stream";
// 构造 multipart body
const header = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="media"; filename="${filename}"; 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: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
// DEBUG: 输出完整响应
console.log(`[wecom-upload] Response:`, JSON.stringify(json));
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<void> {
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: agent.agentId,
[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 };
}