Files
koc-loop/lib/wecom-client.ts

770 lines
23 KiB
TypeScript
Raw Normal View History

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;
userid?: 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));
}
export function hasAppCredentials(config: WecomConfig) {
return Boolean(config.corpId && config.agentId && config.secret);
}
async function readEnvelope(
response: Response,
fallbackMessage: string,
): Promise<WecomEnvelope> {
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<void> {
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 WecomTemplateCardButton = {
key: string;
name: string;
url?: string;
};
export async function sendWecomTemplateCard(
config: WecomConfig,
params: {
touser: string[];
taskId: string;
title: string;
description: string;
url: string;
buttons: WecomTemplateCardButton[];
},
fetchImpl: FetchLike = fetch,
): Promise<{ sent: number; failed: number; skipped: boolean }> {
const normalized = params.touser
.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 taskId = bindingValue(params.taskId);
if (!taskId) {
throw new WecomClientError("模板卡片缺少 task_id(回调识别用)", 400);
}
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: "template_card",
agentid: Number(config.agentId),
template_card: {
card_type: "text_notice",
source: {
desc: "KOC LOOP",
desc_color: 0,
},
main_title: {
title: safeMessage(params.title),
desc: safeMessage(params.description).slice(0, 120),
},
card_action: {
type: 1,
url: bindingValue(params.url),
},
button_list: params.buttons.map((button) => ({
type: button.url ? 1 : 0,
text: safeMessage(button.name),
key: bindingValue(button.key),
...(button.url ? { url: button.url } : {}),
})),
task_id: taskId,
},
}),
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 type WecomGroupChat = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
status: number;
};
export async function listCustomerGroupChats(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<WecomGroupChat[]> {
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<WecomExternalContact[]> {
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;
}
export function buildWecomOAuthUrl(params: {
corpId: string;
agentId: string;
redirectUri: string;
state: string;
}): string {
const query = new URLSearchParams({
appid: params.corpId,
redirect_uri: params.redirectUri,
response_type: "code",
scope: "snsapi_base",
state: params.state,
agentid: params.agentId,
});
return `https://open.weixin.qq.com/connect/oauth2/authorize?${query.toString()}#wechat_redirect`;
}
export type WecomOAuthIdentity = {
userId?: string;
externalUserId?: string;
};
export async function exchangeWecomOAuthCode(
config: WecomConfig,
code: string,
fetchImpl: FetchLike = fetch,
): Promise<WecomOAuthIdentity> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const authorizationCode = bindingValue(code);
if (!authorizationCode) {
throw new WecomClientError("缺少企业微信授权 code", 400);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/auth/getuserinfo?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ code: authorizationCode }),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "企业微信授权身份换取失败");
if (!response.ok) {
throw new WecomClientError(
`企业微信授权身份换取失败(HTTP ${response.status})`,
502,
);
}
if (Number(payload.errcode ?? 0) === 40029) {
throw new WecomClientError("企业微信授权 code 无效或已过期", 400);
}
ensureOk(payload, "企业微信授权身份换取失败");
const userId = bindingValue(payload.userid);
const externalUserId =
typeof payload.external_userid === "string"
? bindingValue(payload.external_userid)
: "";
if (!userId && !externalUserId) {
throw new WecomClientError("企业微信未返回有效身份", 400);
}
return {
...(userId ? { userId } : {}),
...(externalUserId ? { externalUserId } : {}),
};
}
export async function fetchWecomExternalContactName(
config: WecomConfig,
externalUserId: string,
fetchImpl: FetchLike = fetch,
): Promise<string | null> {
if (!hasAppCredentials(config)) return null;
const externalId = bindingValue(externalUserId);
if (!externalId) return null;
try {
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalId)}`;
const response = await fetchImpl(url, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, "获取外部联系人详情失败");
if (!response.ok) return null;
if (Number(payload.errcode ?? 0) !== 0) return null;
const name = bindingValue(payload.external_contact?.name);
return name || null;
} catch {
return null;
}
}
async function postAppChat(
config: WecomConfig,
path: string,
body: Record<string, unknown>,
errorLabel: string,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}${path}?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, errorLabel);
if (!response.ok) {
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
}
ensureOk(payload, errorLabel);
return payload;
}
export async function createAppChat(
config: WecomConfig,
params: { name: string; userlist: string[]; chatid: string },
fetchImpl: FetchLike = fetch,
): Promise<{ chatid: string }> {
const userlist = params.userlist
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (userlist.length < 2) {
throw new WecomClientError("创建群聊会话至少需要 2 名成员", 400);
}
const chatid = bindingValue(params.chatid);
if (!chatid || chatid.length > 32) {
throw new WecomClientError("群聊 chatid 缺失或超过 32 字符", 400);
}
const name = bindingValue(params.name).slice(0, 20);
const payload = await postAppChat(
config,
"/cgi-bin/appchat/create",
{ name, owner: Number(config.agentId), userlist, chatid },
"创建企业微信群聊会话失败",
fetchImpl,
);
return { chatid: bindingValue(payload.chatid) || chatid };
}
export async function updateAppChat(
config: WecomConfig,
params: {
chatid: string;
addUserList?: string[];
delUserList?: string[];
},
fetchImpl: FetchLike = fetch,
): Promise<void> {
const body: Record<string, unknown> = {
chatid: bindingValue(params.chatid),
};
const addUserList = (params.addUserList ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
const delUserList = (params.delUserList ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (addUserList.length === 0 && delUserList.length === 0) return;
if (addUserList.length > 0) body.add_user_list = addUserList;
if (delUserList.length > 0) body.del_user_list = delUserList;
await postAppChat(
config,
"/cgi-bin/appchat/update",
body,
"更新企业微信群聊会话失败",
fetchImpl,
);
}
export async function getAppChat(
config: WecomConfig,
chatid: string,
fetchImpl: FetchLike = fetch,
): Promise<{ chatId: string; name: string; userlist: string[] } | null> {
try {
const payload = await postAppChat(
config,
"/cgi-bin/appchat/get",
{ chatid: bindingValue(chatid) },
"获取企业微信群聊会话失败",
fetchImpl,
);
const detail = payload.chat_info as
| { chatid?: unknown; name?: unknown; userlist?: unknown[] }
| undefined;
if (!detail) return null;
return {
chatId: bindingValue(detail.chatid),
name: bindingValue(detail.name),
userlist: Array.isArray(detail.userlist)
? detail.userlist.map((id: unknown) => bindingValue(id)).filter(Boolean)
: [],
};
} catch {
return null;
}
}
export async function sendAppChatMessage(
config: WecomConfig,
params: { chatid: string; text: string },
fetchImpl: FetchLike = fetch,
): Promise<void> {
const chatid = bindingValue(params.chatid);
const content = bindingValue(params.text);
if (!chatid || !content) return;
await postAppChat(
config,
"/cgi-bin/appchat/send",
{ chatid, safe: 0, msgtype: "text", text: { content } },
"发送企业微信群聊消息失败",
fetchImpl,
);
}