feat: 接入企业微信通知(临期催办 + 群机器人汇总)
新增 lib/wecom-client.ts 与 lib/wecom-notifier-service.ts,支持 群机器人 webhook 与应用消息双通道;scheduler 加入临期 N 天催办 与每日管理员汇总;action 路由补 bind_wecom_external_id 与 send_test_wecom 两个管理端动作,配套测试。
This commit is contained in:
219
lib/wecom-client.ts
Normal file
219
lib/wecom-client.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user