feat: 企微回调服务基建(加解密+事件分发,默认关闭)

This commit is contained in:
ABAPPLO
2026-09-01 15:08:00 +08:00
parent ca3810a3d0
commit b243562e60
5 changed files with 393 additions and 0 deletions

View File

@@ -24,6 +24,9 @@ export type RuntimeEnv = {
WECOM_ROBOT_WEBHOOK?: string;
WECOM_NOTIFY_DUE_DAYS?: string;
WECOM_NOTIFY_ENABLED?: string;
WECOM_CALLBACK_TOKEN?: string;
WECOM_CALLBACK_ENCODING_AES_KEY?: string;
WECOM_CALLBACK_ENABLED?: string;
SEED_DEMO_DATA?: string;
ENABLE_SCHEDULER?: string;
};

View File

@@ -0,0 +1,97 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
timingSafeEqual,
} from "node:crypto";
export class WecomCallbackCryptoError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "WecomCallbackCryptoError";
this.status = status;
}
}
export function resolveWecomCallbackAesKey(encodingAesKey: string): Buffer {
const key = String(encodingAesKey ?? "").trim();
if (!/^[A-Za-z0-9]{43}$/.test(key)) {
throw new WecomCallbackCryptoError(
"企业微信回调 EncodingAESKey 非法(需 43 位字母数字)",
500,
);
}
return Buffer.from(`${key}=`, "base64");
}
export function verifyWecomCallbackSignature(
token: string,
timestamp: string,
nonce: string,
encrypt: string,
): string {
return createHash("sha1")
.update([token, timestamp, nonce, encrypt].sort().join(""))
.digest("hex");
}
export function wecomSignaturesMatch(
expected: string,
received: string,
): boolean {
const a = Buffer.from(String(expected ?? ""));
const b = Buffer.from(String(received ?? ""));
if (a.length !== b.length || a.length === 0) return false;
return timingSafeEqual(a, b);
}
export function encryptWecomCallback(
plainMessage: string,
receiveId: string,
encodingAesKey: string,
): string {
const key = resolveWecomCallbackAesKey(encodingAesKey);
const random = randomBytes(16);
const message = Buffer.from(String(plainMessage ?? ""), "utf8");
const receive = Buffer.from(String(receiveId ?? ""), "utf8");
const length = Buffer.alloc(4);
length.writeUInt32BE(message.length, 0);
const cipher = createCipheriv("aes-256-cbc", key, key.subarray(0, 16));
const encrypted = Buffer.concat([
cipher.update(Buffer.concat([random, length, message, receive])),
cipher.final(),
]);
return encrypted.toString("base64");
}
export function decryptWecomCallback(
encrypted: string,
encodingAesKey: string,
): { message: string; receiveId: string } {
const key = resolveWecomCallbackAesKey(encodingAesKey);
const data = Buffer.from(String(encrypted ?? "").trim(), "base64");
if (data.length === 0 || data.length % 16 !== 0) {
throw new WecomCallbackCryptoError("企业微信回调密文长度非法");
}
let raw: Buffer;
try {
const decipher = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
raw = Buffer.concat([decipher.update(data), decipher.final()]);
} catch {
throw new WecomCallbackCryptoError("企业微信回调密文解密失败");
}
if (raw.length < 20) {
throw new WecomCallbackCryptoError("企业微信回调明文长度非法");
}
const messageLength = raw.readUInt32BE(16);
if (messageLength <= 0 || 20 + messageLength > raw.length) {
throw new WecomCallbackCryptoError("企业微信回调消息长度字段非法");
}
return {
message: raw.subarray(20, 20 + messageLength).toString("utf8"),
receiveId: raw.subarray(20 + messageLength).toString("utf8"),
};
}

View File

@@ -0,0 +1,54 @@
export type WecomCallbackFields = Record<string, string>;
export type WecomCallbackEventResult = {
recognized: boolean;
action: string;
taskId: string;
btnKey: string;
operator: string;
};
export function parseWecomCallbackXml(xml: string): WecomCallbackFields {
const fields: WecomCallbackFields = {};
const source = String(xml ?? "");
const pattern =
/<([A-Za-z][A-Za-z0-9_]*)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
const tag = match[1];
const value = match[2] ?? match[3] ?? "";
if (!(tag in fields)) fields[tag] = value.trim();
}
return fields;
}
export function extractWecomEncryptField(body: string): string {
const fields = parseWecomCallbackXml(body);
return fields.Encrypt ?? "";
}
export function handleWecomCallbackEvent(
fields: WecomCallbackFields,
): WecomCallbackEventResult {
const event = fields.Event ?? "";
const taskId = fields.TaskId ?? "";
const btnKey = fields.BtnKey ?? "";
const operator = fields.FromUserName ?? "";
if (fields.MsgType === "event" && event === "template_card_event") {
if (btnKey === "mark_handled") {
console.info(
"[KOC LOOP] wecom card ack",
{ taskId, operator },
);
return { recognized: true, action: "card_ack", taskId, btnKey, operator };
}
return { recognized: true, action: "ignored", taskId, btnKey, operator };
}
console.info("[KOC LOOP] wecom callback event ignored", {
msgType: fields.MsgType ?? "",
event,
});
return { recognized: false, action: "ignored", taskId, btnKey, operator };
}