import { WecomCrypto } from "./crypto.js";
import { logger } from "./logger.js";
import { MessageDeduplicator } from "./utils.js";
/**
* Basic XML parser for WeCom messages.
*/
function parseWecomXml(xmlString) {
const result = {};
const cdataRegex = /<(\w+)><!\[CDATA\[(.*?)\]\]><\/\1>/gs;
const tagRegex = /<(\w+)>(.*?)<\/\1>/gs;
let match;
while ((match = cdataRegex.exec(xmlString)) !== null) {
result[match[1]] = match[2];
}
while ((match = tagRegex.exec(xmlString)) !== null) {
if (!result[match[1]]) {
result[match[1]] = match[2];
}
}
return result;
}
/**
* WeCom AI Bot & Self-Built App Webhook Handler
* Supports both JSON (AI Bot) and XML (Self-Built App) formats.
*/
export class WecomWebhook {
config;
crypto;
deduplicator = new MessageDeduplicator();
/** Sentinel returned when a message is a duplicate (caller should ACK 200). */
static DUPLICATE = Symbol.for("wecom.duplicate");
constructor(config) {
this.config = config;
this.crypto = new WecomCrypto(config.token, config.encodingAesKey);
logger.debug("WecomWebhook initialized (AI Bot & Self-Built App mode)");
}
handleVerify(query) {
const signature = query.msg_signature;
const timestamp = query.timestamp;
const nonce = query.nonce;
const echostr = query.echostr;
if (!signature || !timestamp || !nonce || !echostr) {
logger.warn("Missing parameters in verify request", { query });
return null;
}
logger.debug("Handling verify request", { timestamp, nonce });
const calcSignature = this.crypto.getSignature(timestamp, nonce, echostr);
if (calcSignature !== signature) {
logger.error("Signature mismatch in verify", {
expected: signature,
calculated: calcSignature,
});
return null;
}
try {
const result = this.crypto.decrypt(echostr);
logger.info("URL verification successful");
return result.message;
} catch (e) {
logger.error("Decrypt failed in verify", {
error: e instanceof Error ? e.message : String(e),
});
return null;
}
}
async handleMessage(query, rawBody) {
const signature = query.msg_signature;
const timestamp = query.timestamp;
const nonce = query.nonce;
if (!signature || !timestamp || !nonce) {
logger.warn("Missing parameters in message request", { query });
return null;
}
let encrypt;
let isXml = false;
if (rawBody.trim().startsWith("<xml>")) {
isXml = true;
try {
const parsedXml = parseWecomXml(rawBody);
encrypt = parsedXml.Encrypt;
logger.debug("Parsed request body as XML", { hasEncrypt: !!encrypt });
} catch (e) {
logger.error("Failed to parse request body as XML", {
error: e instanceof Error ? e.message : String(e),
body: rawBody.substring(0, 200),
});
return null;
}
} else {
try {
const jsonBody = JSON.parse(rawBody);
encrypt = jsonBody.encrypt;
logger.debug("Parsed request body as JSON", { hasEncrypt: !!encrypt });
} catch (e) {
logger.error("Failed to parse request body as JSON", {
error: e instanceof Error ? e.message : String(e),
body: rawBody.substring(0, 200),
});
return null;
}
}
if (!encrypt) {
logger.error("No encrypt field in body (JSON or XML)");
return null;
}
const calcSignature = this.crypto.getSignature(timestamp, nonce, encrypt);
if (calcSignature !== signature) {
logger.error("Signature mismatch in message", {
expected: signature,
calculated: calcSignature,
});
return null;
}
let decryptedContent;
try {
const result = this.crypto.decrypt(encrypt);
decryptedContent = result.message;
logger.debug("Decrypted content", { content: decryptedContent.substring(0, 300) });
} catch (e) {
logger.error("Message decrypt failed", {
error: e instanceof Error ? e.message : String(e),
});
return null;
}
let data;
try {
if (isXml) {
data = parseWecomXml(decryptedContent);
if (data.CreateTime) data.CreateTime = parseInt(data.CreateTime, 10);
if (data.AgentID) data.AgentID = parseInt(data.AgentID, 10);
data.msgtype = data.MsgType?.toLowerCase();
data.from = { userid: data.FromUserName };
data.chatid = data.ToUserName; // Assuming ToUserName for self-built is the chat target for direct messages
data.msgid = data.MsgId;
data.content = data.Content;
data.image = { url: data.PicUrl, mediaId: data.MediaId };
data.voice = { mediaId: data.MediaId, format: data.Format };
data.video = { mediaId: data.MediaId, thumbMediaId: data.ThumbMediaId };
data.location = { latitude: data.Location_X, longitude: data.Location_Y, scale: data.Scale, name: data.Label };
data.link = { title: data.Title, description: data.Description, url: data.Url, picUrl: data.PicUrl };
data.event = { event_type: data.Event?.toLowerCase(), event_key: data.EventKey, latitude: data.Latitude, longitude: data.Longitude, precision: data.Precision };
logger.debug("Parsed decrypted content as XML", { msgtype: data.msgtype, keys: Object.keys(data) });
} else {
data = JSON.parse(decryptedContent);
logger.debug("Parsed decrypted content as JSON", {
msgtype: data.msgtype,
keys: Object.keys(data),
text: JSON.stringify(data.text),
});
}
} catch (e) {
logger.error("Failed to parse decrypted content (JSON/XML)", {
error: e instanceof Error ? e.message : String(e),
content: decryptedContent.substring(0, 200),
isXmlDetected: isXml,
});
return null;
}
const msgtype = data.msgtype;
let normalizedMessage = null;
const commonFields = {
msgId: data.msgid || `msg_${Date.now()}`,
fromUser: data.from?.userid || data.FromUserName || "",
chatType: data.chattype || "single",
chatId: data.chatid || data.ToUserName || "",
aibotId: data.aibotid || "",
responseUrl: data.response_url || "",
query: { timestamp, nonce },
};
if (msgtype === "text") {
normalizedMessage = {
...commonFields,
msgType: "text",
content: data.text?.content || data.Content || "",
quote: data.quote
? {
msgType: data.quote.msgtype,
content: data.quote.text?.content || data.quote.image?.url || "",
}
: null,
};
} else if (msgtype === "stream") {
normalizedMessage = {
stream: { id: data.stream?.id },
query: { timestamp, nonce },
rawData: data,
};
} else if (msgtype === "image") {
normalizedMessage = {
...commonFields,
msgType: "image",
imageUrl: data.image?.url || data.PicUrl || "",
mediaId: data.image?.mediaId || data.MediaId || "",
};
} else if (msgtype === "voice") {
const content = data.voice?.content || data.Recognition || "";
if (content) {
normalizedMessage = {
...commonFields,
msgType: "text",
content,
originalType: "voice",
mediaId: data.voice?.mediaId || data.MediaId || "",
format: data.voice?.format || data.Format || "",
};
} else {
normalizedMessage = {
...commonFields,
msgType: "voice",
mediaId: data.voice?.mediaId || data.MediaId || "",
format: data.voice?.format || data.Format || "",
};
}
} else if (msgtype === "file") {
normalizedMessage = {
...commonFields,
msgType: "file",
fileUrl: data.file?.url || "",
fileName: data.file?.name || data.file?.filename || "",
mediaId: data.file?.mediaId || data.MediaId || "",
};
} else if (msgtype === "location") {
const latitude = data.location?.latitude || data.Location_X || "";
const longitude = data.location?.longitude || data.Location_Y || "";
const name = data.location?.name || data.location?.label || data.Label || "";
const content = name
? `[位置] ${name} (${latitude}, ${longitude})`
: `[位置] ${latitude}, ${longitude}`;
normalizedMessage = {
...commonFields,
msgType: "text",
content,
location: { latitude, longitude, name, scale: data.location?.scale || data.Scale || "" },
};
} else if (msgtype === "link") {
const title = data.link?.title || data.Title || "";
const description = data.link?.description || data.Description || "";
const url = data.link?.url || data.Url || "";
const picUrl = data.link?.picUrl || data.PicUrl || "";
const parts = [];
if (title) parts.push(`[链接] ${title}`);
if (description) parts.push(description);
if (url) parts.push(url);
const content = parts.join("\n") || "[链接]";
normalizedMessage = {
...commonFields,
msgType: "text",
content,
link: { title, description, url, picUrl },
};
} else if (msgtype === "event") {
logger.info("Received event", { event: data.event || data.Event, isXml });
normalizedMessage = {
event: data.event || data.Event,
event_type: data.event?.event_type || data.Event?.toLowerCase(),
fromUser: data.from?.userid || data.FromUserName || "",
agentId: data.agentid || data.AgentID || "",
eventKey: data.event?.event_key || data.EventKey || "",
latitude: data.Latitude,
longitude: data.Longitude,
precision: data.Precision,
};
} else if (msgtype === "mixed") {
logger.warn("Mixed messages are not fully supported in bone version. Converting to text.", { msgtype, isXmlDetected: isXml });
const msgItems = data.mixed?.msg_item || [];
const textParts = [];
for (const item of msgItems) {
if (item.msgtype === "text" && item.text?.content) {
textParts.push(item.text.content);
}
}
const content = textParts.join("\n");
normalizedMessage = {
...commonFields,
msgType: "text", // Fallback to text
content: content || "[Mixed message received]",
};
} else {
logger.warn("Unknown message type or unsupported in bone version", { msgtype, isXmlDetected: isXml });
return null;
}
if (normalizedMessage && normalizedMessage.msgId && this.deduplicator.isDuplicate(normalizedMessage.msgId)) {
logger.debug("Duplicate message ignored", { msgId: normalizedMessage.msgId });
return WecomWebhook.DUPLICATE;
}
if (normalizedMessage && normalizedMessage.message) {
normalizedMessage.message.query = { timestamp, nonce };
} else if (normalizedMessage) {
normalizedMessage.query = { timestamp, nonce };
}
return normalizedMessage;
}
buildStreamResponse(streamId, content, finish, timestamp, nonce, options = {}) {
const stream = {
id: streamId,
finish: finish,
content: content,
};
if (options.msgItem && options.msgItem.length > 0) {
stream.msg_item = options.msgItem;
}
if (options.feedbackId) {
stream.feedback = { id: options.feedbackId };
}
const plain = {
msgtype: "stream",
stream: stream,
};
const plainStr = JSON.stringify(plain);
const encrypted = this.crypto.encrypt(plainStr);
const signature = this.crypto.getSignature(timestamp, nonce, encrypted);
return JSON.stringify({
encrypt: encrypted,
msgsignature: signature,
timestamp: timestamp,
nonce: nonce,
});
}
buildPassiveReplyXml(toUser, fromUser, msgType, contentOrMediaId, timestamp, nonce, options = {}) {
let innerXml = "";
if (msgType === "text") {
innerXml = `<ToUserName><![CDATA[${toUser}]]></ToUserName>
<FromUserName><![CDATA[${fromUser}]]></FromUserName>
<CreateTime>${timestamp}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[${contentOrMediaId}]]></Content>`;
} else if (msgType === "image") {
innerXml = `<ToUserName><![CDATA[${toUser}]]></ToUserName>
<FromUserName><![CDATA[${fromUser}]]></FromUserName>
<CreateTime>${timestamp}</CreateTime>
<MsgType><![CDATA[image]]></MsgType>
<Image><MediaId><![CDATA[${contentOrMediaId}]]></MediaId></Image>`;
} else {
logger.warn("Unsupported message type for passive XML reply", { msgType });
return null;
}
const plainXml = `<xml>${innerXml}</xml>`;
const encrypted = this.crypto.encrypt(plainXml);
const signature = this.crypto.getSignature(timestamp, nonce, encrypted);
return `<xml>
<Encrypt><![CDATA[${encrypted}]]></Encrypt>
<MsgSignature><![CDATA[${signature}]]></MsgSignature>
<TimeStamp>${timestamp}</TimeStamp>
<Nonce><![CDATA[${nonce}]]></Nonce>
</xml>`;
}
}