const WECOM_API_ORIGIN = "https://qyapi.weixin.qq.com"; const DEFAULT_DUE_DAYS = 3; export type WecomBindings = { WECOM_CORP_ID?: string; WECOM_AGENT_ID?: string; WECOM_SECRET?: string; WECOM_ROBOT_WEBHOOK?: string; WECOM_NOTIFY_DUE_DAYS?: string; }; export type WecomConfig = { corpId: string; agentId: string; secret: string; robotWebhook: string; dueDays: number; }; type FetchLike = typeof fetch; type WecomEnvelope = { errcode?: number; errmsg?: string; access_token?: string; expires_in?: number; invaliduser?: string; }; type CachedAccessToken = { corpId: string; secret: string; token: string; expiresAt: number; }; let cachedAccessToken: CachedAccessToken | null = null; export class WecomClientError extends Error { status: number; constructor(message: string, status = 502) { super(message); this.name = "WecomClientError"; this.status = status; } } function bindingValue(value: unknown) { return String(value ?? "").trim(); } function safeMessage(value: unknown) { return String(value ?? "").trim().slice(0, 240); } export function resolveWecomConfig(bindings: WecomBindings): WecomConfig { return { corpId: bindingValue(bindings.WECOM_CORP_ID), agentId: bindingValue(bindings.WECOM_AGENT_ID), secret: bindingValue(bindings.WECOM_SECRET), robotWebhook: bindingValue(bindings.WECOM_ROBOT_WEBHOOK), dueDays: parseDueDays(bindings.WECOM_NOTIFY_DUE_DAYS), }; } function parseDueDays(value: string | undefined) { const parsed = Number(bindingValue(value)); if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_DUE_DAYS; return Math.min(30, Math.floor(parsed)); } function hasAppCredentials(config: WecomConfig) { return Boolean(config.corpId && config.agentId && config.secret); } async function readEnvelope( response: Response, fallbackMessage: string, ): Promise { const text = await response.text(); try { return JSON.parse(text) as WecomEnvelope; } catch { throw new WecomClientError( `${fallbackMessage}(企业微信返回了非 JSON 响应)`, 502, ); } } function ensureOk( payload: WecomEnvelope, fallbackMessage: string, ) { const code = Number(payload.errcode ?? 0); if (code === 0) return; const message = safeMessage(payload.errmsg) || fallbackMessage; if (code === 40014 || code === 42001) { throw new WecomClientError(`企业微信 access_token 无效:${message}`, 401); } throw new WecomClientError(`${fallbackMessage}(${message})`, 502); } async function fetchAccessToken( config: WecomConfig, fetchImpl: FetchLike, ) { if ( cachedAccessToken?.corpId === config.corpId && cachedAccessToken?.secret === config.secret && cachedAccessToken.expiresAt > Date.now() + 60_000 ) { return cachedAccessToken.token; } const url = new URL(`${WECOM_API_ORIGIN}/cgi-bin/gettoken`); url.searchParams.set("corpid", config.corpId); url.searchParams.set("corpsecret", config.secret); const response = await fetchImpl(url.toString(), { method: "GET", signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, "获取企业微信 access_token 失败"); if (!response.ok) { throw new WecomClientError( `获取企业微信 access_token 失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "获取企业微信 access_token 失败"); const token = bindingValue(payload.access_token); if (!token) { throw new WecomClientError("企业微信未返回有效 access_token", 502); } cachedAccessToken = { corpId: config.corpId, secret: config.secret, token, expiresAt: Date.now() + Math.max(300, Number(payload.expires_in) || 7_200) * 1_000, }; return token; } export async function sendWecomRobotMessage( content: string, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { if (!config.robotWebhook) { console.warn("[KOC LOOP] wecom robot webhook not configured, skipping"); return; } const response = await fetchImpl(config.robotWebhook, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ msgtype: "text", text: { content }, }), signal: AbortSignal.timeout(10_000), }); const payload = await readEnvelope(response, "企业微信群机器人推送失败"); if (!response.ok) { throw new WecomClientError( `企业微信群机器人推送失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "企业微信群机器人推送失败"); } export async function sendWecomAppMessage( externalUserIds: string[], content: string, config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise<{ sent: number; failed: number; skipped: boolean }> { const normalized = externalUserIds .map((id) => bindingValue(id)) .filter((id) => id.length > 0); if (normalized.length === 0) { return { sent: 0, failed: 0, skipped: true }; } if (!hasAppCredentials(config)) { return { sent: 0, failed: 0, skipped: true }; } const token = await fetchAccessToken(config, fetchImpl); const url = `${WECOM_API_ORIGIN}/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`; const response = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ touser: normalized.join("|"), msgtype: "text", agentid: Number(config.agentId), text: { content }, }), signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, "企业微信应用消息推送失败"); if (!response.ok) { throw new WecomClientError( `企业微信应用消息推送失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "企业微信应用消息推送失败"); const invalid = bindingValue(payload.invaliduser).split("|").filter(Boolean); return { sent: Math.max(0, normalized.length - invalid.length), failed: invalid.length, skipped: false, }; } export function clearWecomAccessTokenCacheForTests() { cachedAccessToken = null; }