2026-08-18 16:39:59 +08:00
|
|
|
|
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;
|
2026-08-31 17:10:03 +08:00
|
|
|
|
userid?: string;
|
2026-08-20 15:16:18 +08:00
|
|
|
|
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;
|
2026-08-18 16:39:59 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
// 企微 appchat 的 chatid 只接受字母和数字(下划线/连字符都会报 86001 chatid invalid)
|
|
|
|
|
|
export function sanitizeChatid(value: string) {
|
|
|
|
|
|
return String(value ?? "").replace(/[^a-zA-Z0-9]/g, "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 11:30:26 +08:00
|
|
|
|
export function hasAppCredentials(config: WecomConfig) {
|
2026-08-18 16:39:59 +08:00
|
|
|
|
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,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
allowErrcodes: number[] = [],
|
2026-08-18 16:39:59 +08:00
|
|
|
|
) {
|
|
|
|
|
|
const code = Number(payload.errcode ?? 0);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
if (code === 0 || allowErrcodes.includes(code)) return;
|
2026-08-18 16:39:59 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-20 15:16:18 +08:00
|
|
|
|
|
2026-09-01 17:37:35 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 15:16:18 +08:00
|
|
|
|
export type WecomGroupChat = {
|
|
|
|
|
|
chatId: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
ownerUserId: string;
|
|
|
|
|
|
memberCount: number;
|
|
|
|
|
|
status: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export async function listCustomerGroupChats(
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
ownerUserids?: string[],
|
2026-08-20 15:16:18 +08:00
|
|
|
|
): Promise<WecomGroupChat[]> {
|
|
|
|
|
|
if (!hasAppCredentials(config)) return [];
|
|
|
|
|
|
const token = await fetchAccessToken(config, fetchImpl);
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const ownerFilterUserids = (ownerUserids ?? [])
|
|
|
|
|
|
.map((id) => bindingValue(id))
|
|
|
|
|
|
.filter((id) => id.length > 0);
|
2026-08-20 15:16:18 +08:00
|
|
|
|
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" },
|
2026-09-04 18:03:46 +08:00
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
limit: 100,
|
|
|
|
|
|
cursor,
|
|
|
|
|
|
...(ownerFilterUserids.length > 0
|
|
|
|
|
|
? { owner_filter: { userid_list: ownerFilterUserids } }
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
}),
|
2026-08-20 15:16:18 +08:00
|
|
|
|
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;
|
2026-09-23 11:13:17 +08:00
|
|
|
|
const detail = getPayload.group_chat as
|
|
|
|
|
|
| {
|
|
|
|
|
|
name?: unknown;
|
|
|
|
|
|
owner?: unknown;
|
|
|
|
|
|
member_count?: unknown;
|
|
|
|
|
|
member_list?: unknown[];
|
|
|
|
|
|
}
|
|
|
|
|
|
| undefined;
|
|
|
|
|
|
// 企微实际响应不带 member_count(只有 member_list),以成员列表长度为准
|
|
|
|
|
|
const memberCount = Array.isArray(detail?.member_list)
|
|
|
|
|
|
? detail.member_list.length
|
|
|
|
|
|
: Number(detail?.member_count ?? 0);
|
2026-08-20 15:16:18 +08:00
|
|
|
|
groups.push({
|
|
|
|
|
|
chatId,
|
|
|
|
|
|
name: bindingValue(detail?.name),
|
|
|
|
|
|
ownerUserId: bindingValue(detail?.owner),
|
2026-09-23 11:13:17 +08:00
|
|
|
|
memberCount,
|
2026-08-20 15:16:18 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-31 17:10:03 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-03 11:30:26 +08:00
|
|
|
|
|
|
|
|
|
|
async function postAppChat(
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
path: string,
|
|
|
|
|
|
body: Record<string, unknown>,
|
|
|
|
|
|
errorLabel: string,
|
|
|
|
|
|
fetchImpl: FetchLike,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
allowErrcodes: number[] = [],
|
2026-09-03 11:30:26 +08:00
|
|
|
|
): 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);
|
|
|
|
|
|
}
|
2026-09-04 18:03:46 +08:00
|
|
|
|
ensureOk(payload, errorLabel, allowErrcodes);
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
// owner 不传:默认建群应用自己为群主,避免把 agentId 误当 userid
|
|
|
|
|
|
{ name, userlist, chatid },
|
2026-09-03 11:30:26 +08:00
|
|
|
|
"创建企业微信群聊会话失败",
|
|
|
|
|
|
fetchImpl,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
// 86215 = chatid 已存在:chatid 由本项目派生(pgc/tgc+ID),视为本应用已建过的群
|
|
|
|
|
|
[86215],
|
2026-09-03 11:30:26 +08:00
|
|
|
|
);
|
|
|
|
|
|
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,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
export type TaskJoinWayInput = {
|
|
|
|
|
|
chatIdList: string[];
|
|
|
|
|
|
roomBaseName: string;
|
|
|
|
|
|
state: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function joinWayBody(input: TaskJoinWayInput): Record<string, unknown> {
|
|
|
|
|
|
return {
|
|
|
|
|
|
scene: 2,
|
|
|
|
|
|
chat_id_list: input.chatIdList
|
|
|
|
|
|
.map((id) => bindingValue(id))
|
|
|
|
|
|
.filter((id) => id.length > 0)
|
|
|
|
|
|
.slice(0, 5),
|
|
|
|
|
|
auto_create_room: 1,
|
|
|
|
|
|
room_base_name: Array.from(input.roomBaseName).slice(0, 40).join(""),
|
|
|
|
|
|
state: Array.from(input.state).slice(0, 30).join(""),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function addTaskJoinWay(
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
input: TaskJoinWayInput,
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
|
|
|
|
|
): Promise<{ configId: string }> {
|
|
|
|
|
|
const payload = await postAppChat(
|
|
|
|
|
|
config,
|
|
|
|
|
|
"/cgi-bin/externalcontact/groupchat/add_join_way",
|
|
|
|
|
|
joinWayBody(input),
|
|
|
|
|
|
"创建企业微信进群活码失败",
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
const configId = bindingValue(payload.config_id);
|
|
|
|
|
|
if (!configId) {
|
|
|
|
|
|
throw new WecomClientError("创建企业微信进群活码失败(未返回 config_id)", 502);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { configId };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getJoinWayQrCode(
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
configId: string,
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
|
|
|
|
|
): Promise<{ qrCode: string }> {
|
|
|
|
|
|
const payload = await postAppChat(
|
|
|
|
|
|
config,
|
|
|
|
|
|
"/cgi-bin/externalcontact/groupchat/get_join_way",
|
|
|
|
|
|
{ config_id: bindingValue(configId) },
|
|
|
|
|
|
"获取企业微信进群活码二维码失败",
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
const joinWay = payload.join_way as { qr_code?: unknown } | undefined;
|
|
|
|
|
|
const qrCode = bindingValue(joinWay?.qr_code);
|
|
|
|
|
|
if (!qrCode) {
|
|
|
|
|
|
throw new WecomClientError("获取企业微信进群活码二维码失败(未返回二维码)", 502);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { qrCode };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function updateTaskJoinWay(
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
input: TaskJoinWayInput & { configId: string },
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const configId = bindingValue(input.configId);
|
|
|
|
|
|
if (!configId) {
|
|
|
|
|
|
throw new WecomClientError("更新企业微信进群活码失败(缺少 config_id)", 400);
|
|
|
|
|
|
}
|
|
|
|
|
|
// update_join_way 为覆盖式更新,需重传全部字段(含 scene / auto_create_room)
|
|
|
|
|
|
await postAppChat(
|
|
|
|
|
|
config,
|
|
|
|
|
|
"/cgi-bin/externalcontact/groupchat/update_join_way",
|
|
|
|
|
|
{ ...joinWayBody(input), config_id: configId },
|
|
|
|
|
|
"更新企业微信进群活码失败",
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deleteJoinWay(
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
configId: string,
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const configIdValue = bindingValue(configId);
|
|
|
|
|
|
if (!configIdValue) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await postAppChat(
|
|
|
|
|
|
config,
|
|
|
|
|
|
"/cgi-bin/externalcontact/groupchat/del_join_way",
|
|
|
|
|
|
{ config_id: configIdValue },
|
|
|
|
|
|
"删除企业微信进群活码失败",
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// 活码配置可能已被企微侧清理(群解散等),删除是尽力而为,不阻断本地解绑
|
|
|
|
|
|
if (error instanceof WecomClientError) return;
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 13:52:10 +08:00
|
|
|
|
async function getWecomJson(
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
path: string,
|
|
|
|
|
|
params: Record<string, string>,
|
|
|
|
|
|
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 query = new URLSearchParams({ access_token: token, ...params });
|
|
|
|
|
|
const response = await fetchImpl(`${WECOM_API_ORIGIN}${path}?${query}`, {
|
|
|
|
|
|
method: "GET",
|
|
|
|
|
|
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 as Record<string, unknown>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type WecomInternalContact = {
|
|
|
|
|
|
userid: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
departmentNames: string[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const INTERNAL_CONTACT_DEPARTMENT_LIMIT = 100;
|
|
|
|
|
|
const INTERNAL_CONTACT_CURSOR_ROUNDS = 10;
|
|
|
|
|
|
|
|
|
|
|
|
export async function listInternalContacts(
|
|
|
|
|
|
config: WecomConfig,
|
|
|
|
|
|
fetchImpl: FetchLike = fetch,
|
|
|
|
|
|
): Promise<WecomInternalContact[]> {
|
|
|
|
|
|
const deptPayload = await getWecomJson(
|
|
|
|
|
|
config,
|
|
|
|
|
|
"/cgi-bin/department/list",
|
|
|
|
|
|
{},
|
|
|
|
|
|
"获取企业微信部门列表失败",
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
const departments = (
|
|
|
|
|
|
Array.isArray(deptPayload.department)
|
|
|
|
|
|
? (deptPayload.department as { id?: unknown; name?: unknown }[])
|
|
|
|
|
|
: []
|
|
|
|
|
|
).slice(0, INTERNAL_CONTACT_DEPARTMENT_LIMIT);
|
|
|
|
|
|
const departmentNames = new Map<string, string>();
|
|
|
|
|
|
for (const dept of departments) {
|
|
|
|
|
|
const id = String(dept?.id ?? "");
|
|
|
|
|
|
if (id) departmentNames.set(id, bindingValue(dept?.name));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const contacts = new Map<string, WecomInternalContact>();
|
|
|
|
|
|
for (const dept of departments) {
|
|
|
|
|
|
const departmentId = String(dept?.id ?? "");
|
|
|
|
|
|
if (!departmentId) continue;
|
|
|
|
|
|
let cursor = "";
|
|
|
|
|
|
for (let round = 0; round < INTERNAL_CONTACT_CURSOR_ROUNDS; round += 1) {
|
|
|
|
|
|
const params: Record<string, string> = {
|
|
|
|
|
|
department_id: departmentId,
|
|
|
|
|
|
fetch_child: "0",
|
|
|
|
|
|
};
|
|
|
|
|
|
if (cursor) params.cursor = cursor;
|
|
|
|
|
|
const payload = await getWecomJson(
|
|
|
|
|
|
config,
|
|
|
|
|
|
"/cgi-bin/user/simplelist",
|
|
|
|
|
|
params,
|
|
|
|
|
|
"获取企业微信部门成员失败",
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
const userlist = Array.isArray(payload.userlist)
|
|
|
|
|
|
? (payload.userlist as {
|
|
|
|
|
|
userid?: unknown;
|
|
|
|
|
|
name?: unknown;
|
|
|
|
|
|
department?: unknown[];
|
|
|
|
|
|
}[])
|
|
|
|
|
|
: [];
|
|
|
|
|
|
for (const user of userlist) {
|
|
|
|
|
|
const userid = bindingValue(user?.userid);
|
|
|
|
|
|
if (!userid) continue;
|
|
|
|
|
|
const name = bindingValue(user?.name);
|
|
|
|
|
|
const deptIds = (
|
|
|
|
|
|
Array.isArray(user?.department)
|
|
|
|
|
|
? user.department.map((id) => String(id)).filter(Boolean)
|
|
|
|
|
|
: [departmentId]
|
|
|
|
|
|
);
|
|
|
|
|
|
const existing = contacts.get(userid);
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
if (!existing.name && name) existing.name = name;
|
|
|
|
|
|
for (const deptId of deptIds) {
|
|
|
|
|
|
const deptName = departmentNames.get(deptId);
|
|
|
|
|
|
if (deptName && !existing.departmentNames.includes(deptName)) {
|
|
|
|
|
|
existing.departmentNames.push(deptName);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
contacts.set(userid, {
|
|
|
|
|
|
userid,
|
|
|
|
|
|
name,
|
|
|
|
|
|
departmentNames: deptIds
|
|
|
|
|
|
.map((id) => departmentNames.get(id))
|
|
|
|
|
|
.filter((deptName): deptName is string => Boolean(deptName)),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const nextCursor = bindingValue(payload.next_cursor);
|
|
|
|
|
|
if (!nextCursor || nextCursor === cursor) break;
|
|
|
|
|
|
cursor = nextCursor;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...contacts.values()].sort((a, b) =>
|
|
|
|
|
|
`${a.name}${a.userid}`.localeCompare(`${b.name}${b.userid}`, "zh"),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|