Newer
Older
WeComCompanyPlugin / webhook.js
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>`;
  }
}