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; follow_user?: Array<{ userid: string }>; external_userid?: string[]; external_contact?: { external_userid?: string; name?: string; avatar?: string; corp_fullname?: string; }; next_cursor?: string; group_chat_list?: Array<{ chat_id?: string; status?: number }>; group_chat?: { name?: string; owner?: string; member_count?: number; }; fail_list?: string[]; msgid?: string; }; export type WecomExternalContact = { externalUserId: string; name: string; avatar: string; corpName: string; ownerUserId: 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; } export type WecomGroupChat = { chatId: string; name: string; ownerUserId: string; memberCount: number; status: number; }; export async function listCustomerGroupChats( config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { if (!hasAppCredentials(config)) return []; const token = await fetchAccessToken(config, fetchImpl); const chatIds: Array<{ chatId: string; status: number }> = []; let cursor = ""; do { const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/groupchat/list?access_token=${encodeURIComponent(token)}`; const listResp = await fetchImpl(listUrl, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ limit: 100, cursor }), signal: AbortSignal.timeout(12_000), }); const listPayload = await readEnvelope(listResp, "获取企业微信客户群列表失败"); if (!listResp.ok) { throw new WecomClientError( `获取企业微信客户群列表失败(HTTP ${listResp.status})`, 502, ); } ensureOk(listPayload, "获取企业微信客户群列表失败"); for (const chat of listPayload.group_chat_list ?? []) { const chatId = bindingValue(chat.chat_id); if (chatId) chatIds.push({ chatId, status: Number(chat.status ?? 0) }); } cursor = bindingValue(listPayload.next_cursor); } while (cursor); const groups: WecomGroupChat[] = []; for (const { chatId, status } of chatIds) { if (status !== 0) continue; const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/groupchat/get?access_token=${encodeURIComponent(token)}`; const getResp = await fetchImpl(getUrl, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ chat_id: chatId }), signal: AbortSignal.timeout(12_000), }); const getPayload = await readEnvelope(getResp, "获取企业微信客户群详情失败"); if (!getResp.ok) continue; if (Number(getPayload.errcode ?? 0) !== 0) continue; const detail = getPayload.group_chat; groups.push({ chatId, name: bindingValue(detail?.name), ownerUserId: bindingValue(detail?.owner), memberCount: Number(detail?.member_count ?? 0), status, }); } return groups; } export type WecomGroupMsgLink = { title: string; desc?: string; url: string; picurl?: string; }; export async function createGroupMsgTemplate( config: WecomConfig, params: { sender: string; chatIdList: string[]; text?: string; link?: WecomGroupMsgLink; }, fetchImpl: FetchLike = fetch, ): Promise<{ msgid: string; failList: string[] }> { if (!hasAppCredentials(config)) { throw new WecomClientError( "企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)", 400, ); } const chatIdList = params.chatIdList.map(bindingValue).filter(Boolean); if (chatIdList.length === 0) { throw new WecomClientError("客户群列表为空", 400); } const sender = bindingValue(params.sender); if (!sender) { throw new WecomClientError("发送成员(群主 userid)不能为空", 400); } const text = String(params.text ?? "").trim(); const link = params.link; if (!text && !link) { throw new WecomClientError("文本与图文附件不能同时为空", 400); } const token = await fetchAccessToken(config, fetchImpl); const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/add_msg_template?access_token=${encodeURIComponent(token)}`; const response = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ chat_type: "group", chat_id_list: chatIdList, sender, ...(text ? { text: { content: text } } : {}), ...(link ? { attachments: [ { msgtype: "link", link: { title: link.title, desc: link.desc ?? "", url: link.url, picurl: link.picurl ?? "", }, }, ], } : {}), }), signal: AbortSignal.timeout(12_000), }); const payload = await readEnvelope(response, "创建企业群发失败"); if (!response.ok) { throw new WecomClientError( `创建企业群发失败(HTTP ${response.status})`, 502, ); } ensureOk(payload, "创建企业群发失败"); return { msgid: bindingValue(payload.msgid), failList: (payload.fail_list ?? []).filter(Boolean), }; } export async function listExternalContacts( config: WecomConfig, fetchImpl: FetchLike = fetch, ): Promise { if (!hasAppCredentials(config)) return []; const token = await fetchAccessToken(config, fetchImpl); const followUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get_follow_user_list?access_token=${encodeURIComponent(token)}`; const followResp = await fetchImpl(followUrl, { method: "GET", signal: AbortSignal.timeout(12_000), }); const followPayload = await readEnvelope( followResp, "获取企业微信跟进人列表失败", ); if (!followResp.ok) { throw new WecomClientError( `获取企业微信跟进人列表失败(HTTP ${followResp.status})`, 502, ); } ensureOk(followPayload, "获取企业微信跟进人列表失败"); const ownerUserIds = (followPayload.follow_user ?? []) .map((user) => bindingValue(user.userid)) .filter(Boolean); const ownerToExternalIds: Array<{ externalUserId: string; ownerUserId: string }> = []; for (const ownerUserId of ownerUserIds) { const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/list?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(ownerUserId)}`; const listResp = await fetchImpl(listUrl, { method: "GET", signal: AbortSignal.timeout(12_000), }); const listPayload = await readEnvelope( listResp, "获取企业微信外部联系人列表失败", ); if (!listResp.ok) continue; if (Number(listPayload.errcode ?? 0) !== 0) continue; const externalIds = (listPayload.external_userid ?? []).filter(Boolean); for (const externalUserId of externalIds) { ownerToExternalIds.push({ externalUserId, ownerUserId }); } } const contacts: WecomExternalContact[] = []; for (const { externalUserId, ownerUserId } of ownerToExternalIds) { const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalUserId)}`; const getResp = await fetchImpl(getUrl, { method: "GET", signal: AbortSignal.timeout(12_000), }); const getPayload = await readEnvelope( getResp, "获取企业微信外部联系人详情失败", ); if (!getResp.ok) continue; if (Number(getPayload.errcode ?? 0) !== 0) continue; const info = getPayload.external_contact; if (!info) continue; contacts.push({ externalUserId, name: bindingValue(info.name), avatar: bindingValue(info.avatar), corpName: bindingValue(info.corp_fullname) || bindingValue(info.name), ownerUserId, }); } return contacts; }