diff --git a/src/index.ts b/src/index.ts index e8340da..7102643 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { defineEndpoint } from "@directus/extensions-sdk"; -import { parseStringXML, parseWechatEncrypt, parseWechatMessage } from "./utils/xml-parser"; +import { parseDecryptedXML, parseWechatEncryptMessage } from "./utils/wechatXmlParser"; import { verifyEncrypt, verifySignature } from "./utils/verification"; import crypto from 'crypto' import { WechatCrypto } from "./utils/wechatCrypto"; @@ -18,17 +18,12 @@ export default defineEndpoint({ return res.status(403).send('Invalid signature'); }); - router.post("/echo", async (req, res) => { - const jsonData = await parseWechatMessage(req); - return res.json({ received: jsonData }); - }); - router.post("/", async (req, res) => { // 验证Encrypt签名 const { timestamp, nonce, msg_signature } = req.query; const token = env.WECHAT_TOKEN; - const encryptData = await parseWechatEncrypt(req); + const encryptData = await parseWechatEncryptMessage(req); const encrypt = encryptData.Encrypt; if (!verifyEncrypt(token as string, encrypt as string, timestamp as string, nonce as string, msg_signature as string)) { @@ -41,14 +36,15 @@ export default defineEndpoint({ const appId = env.WECHAT_APPID; const cryptoUtil = new WechatCrypto(encodingAESKey, appId); const decryptXml = cryptoUtil.decrypt(encrypt); - const msg = await parseStringXML(decryptXml); + const msg = await parseDecryptedXML(decryptXml); + console.log("Parsed Message:", msg); let replyContent; - if (msg.MsgType !== 'text') { - replyContent = "暂不支持该消息类型"; - } else { - replyContent = `已收到你的加密消息:${msg.Content}`; - - } + // if (msg.MsgType !== 'text') { + // replyContent = "暂不支持该消息类型"; + // } else { + // replyContent = `已收到你的加密消息:${msg.Content}, 消息类型: ${msg.MsgType}`; + // } + replyContent = `已收到你的加密消息,消息类型: ${msg.MsgType}`; // 回复消息(示例:文本) const reply = ` diff --git a/src/types/encrypted-message.ts b/src/types/encrypted-message.ts new file mode 100644 index 0000000..c3cac36 --- /dev/null +++ b/src/types/encrypted-message.ts @@ -0,0 +1,18 @@ +/** + * 微信公众号/服务号加密信息类型定义 + * + * @remarks + * - 对应微信官方XML加密消息结构 + * - 在公众平台开启安全模式后,接收的信息均为采用AES加密算法加密的信息 + * @see 微信官方文档: https://developers.weixin.qq.com/doc/service/guide/dev/push/encryption.html + * + * @packageDocumentation + */ + +/** + * 加密XML消息结构 + */ +export interface WechatEncryptMessage { + ToUserName: string; + Encrypt: string; +} \ No newline at end of file diff --git a/src/types/received-message.ts b/src/types/received-message.ts index 5f77cca..91d95b9 100644 --- a/src/types/received-message.ts +++ b/src/types/received-message.ts @@ -5,11 +5,26 @@ * - 对应微信官方XML消息结构 * - 所有消息类型继承自 {@link WechatReceivedBaseMessage} * - 通过 {@link MsgType} 字段区分不同消息类型 - * - 见微信官方文档:https://developers.weixin.qq.com/doc/service/guide/product/message/Receiving_standard_messages.html + * @see 微信官方文档: https://developers.weixin.qq.com/doc/service/guide/product/message/Receiving_standard_messages.html * * @packageDocumentation */ + +/** + * 「接收普通消息」消息类型字面量联合类型 + * + * @remarks + * 可用于类型收窄 + */ +export type WechatReceivedMessageType = | "text" + | "image" + | "voice" + | "video" + | "shortvideo" + | "location" + | "link"; + /** * 「接收普通消息」基础类型定义 * @@ -28,7 +43,7 @@ export interface WechatReceivedBaseMessage { * @remarks * 在具体消息类型中收窄为字面量类型(如"text","image"等) */ - MsgType: string; + MsgType: WechatReceivedMessageType; /** 消息id,64位整型 */ MsgId: string; /** 消息数据id,消息来自文章时存在 */ @@ -127,4 +142,5 @@ export type WechatReceivedMessage = | WechatReceivedVoiceMessage | WechatReceivedVideoMessage | WechatReceivedLocationMessage - | WechatReceivedLinkMessage; \ No newline at end of file + | WechatReceivedLinkMessage; + \ No newline at end of file diff --git a/src/types/wechat-xml.ts b/src/types/wechat-xml.ts new file mode 100644 index 0000000..258dbe3 --- /dev/null +++ b/src/types/wechat-xml.ts @@ -0,0 +1,32 @@ +/** + * 微信XML CDATA节点 + */ +export interface WechatXmlCdataNode { + __cdata?: string; +} + +/** + * 微信XML值类型定义 + */ +export type WechatXmlValue = + | string + | WechatXmlCdataNode; + +/** + * 微信XML对象类型定义 + */ +export type WechatXmlObject = Record; + +/** + * 微信XML归一化对象值类型定义 + */ +type WechatNormalizedValue = + | string + | undefined; + +/** + * 微信XML归一化对象 + */ +export type NormalizedWechatXml> = { + [K in keyof T]: WechatNormalizedValue; +}; \ No newline at end of file diff --git a/src/utils/assertUtils.ts b/src/utils/assertUtils.ts new file mode 100644 index 0000000..ea7fa96 --- /dev/null +++ b/src/utils/assertUtils.ts @@ -0,0 +1,33 @@ +/** + * 断言值为字符串类型 + * @param value 原始值 + * @param fieldName 字段名称 + */ +export function assertString(value: unknown, fieldName: string): asserts value is string { + if (typeof value !== 'string') { + throw new Error(`Expected ${fieldName} to be a string, but got ${typeof value}`); + } +} + +const WECHAT_RECEIVED_MESSAGE_TYPES = [ + "text", + "image", + "voice", + "video", + "shortvideo", + "location", + "link" +] as const; + +export type WechatReceivedMessageType = typeof WECHAT_RECEIVED_MESSAGE_TYPES[number]; + +/** 断言值为微信接收消息类型字面量 + * + * @param value 原始值 + * @param fieldName 字段名称 + */ +export function assertWechatReceivedMessageType(value: unknown, fieldName: string): asserts value is WechatReceivedMessageType { + if (typeof value !== 'string' || !WECHAT_RECEIVED_MESSAGE_TYPES.includes(value as WechatReceivedMessageType)) { + throw new Error(`Expected ${fieldName} to be one of ${WECHAT_RECEIVED_MESSAGE_TYPES.join(", ")}, but got ${typeof value === 'string' ? value : typeof value}`); + } +} \ No newline at end of file diff --git a/src/utils/verification.ts b/src/utils/verification.ts index 4a05806..fa599de 100644 --- a/src/utils/verification.ts +++ b/src/utils/verification.ts @@ -1,5 +1,27 @@ +/** + * 验证签名和加密消息 + * + * @remarks + * - 用于验证微信公众平台/服务号推送消息的签名和加密消息的完整性 + * - @see 微信官方文档: https://developers.weixin.qq.com/doc/service/guide/dev/push/encryption.html + * + * @packageDocumentation + */ + import crypto from 'crypto'; +/** + * 明文模式下的签名验证 + * + * 微信推送消息时,会以携带有signature、timestamp、nonce参数的URL发送请求, + * 本方法接收预设的token与这些参数进行SHA1签名比对,以验证消息的真实性。 + * + * @param token 预设的token,在微信公众平台/服务号后台配置 + * @param signature URL中的signature参数,用于最终签名比对 + * @param timestamp URL中的timestamp参数 + * @param nonce URL中的nonce参数 + * @returns 验证结果,true表示验证通过,false表示验证失败 + */ export function verifySignature(token: string, signature: string, timestamp: string, nonce: string): boolean { const tempArray = [token, timestamp, nonce].sort(); const str = tempArray.join(''); @@ -8,6 +30,19 @@ export function verifySignature(token: string, signature: string, timestamp: str return hash === signature } +/** + * 安全模式下的加密消息签名验证 + * + * 微信推送加密消息时,会以携带有msg_signature、timestamp、nonce参数的URL发送请求, + * 本方法接收预设的token与这些参数及加密消息进行SHA1签名比对,以验证加密消息的真实性。 + * + * @param token 预设的token,在微信公众平台/服务号后台配置 + * @param encrypt 请求包体内XML的encrypt字段内容 + * @param timestamp URL中的timestamp参数 + * @param nonce URL中的nonce参数 + * @param msg_signature URL中的msg_signature参数,用于最终签名比对 + * @returns 验证结果,true表示验证通过,false表示验证失败 + */ export function verifyEncrypt(token: string, encrypt: string, timestamp: string, nonce: string, msg_signature: string): boolean { const tempArray = [token, timestamp, nonce, encrypt].sort(); const str = tempArray.join(''); diff --git a/src/utils/wechatCrypto.ts b/src/utils/wechatCrypto.ts index 6fd9b6f..ec7d2b3 100644 --- a/src/utils/wechatCrypto.ts +++ b/src/utils/wechatCrypto.ts @@ -1,36 +1,97 @@ import crypto from 'crypto'; /** - * 微信AES加解密工具 + * 微信消息 AES-256-CBC 加解密工具类 + * + * #### 适用场景 + * - 微信公众号 / 企业微信 **消息体加解密** + * - 微信服务器推送的 `Encrypt` 字段解密 + * - 被动回复消息体的加密 + * + * #### 加解密规范(微信官方) + * - 算法:AES-256-CBC + * - Key:由 `EncodingAESKey` Base64 解码得到(32 字节) + * - IV:AESKey 前 16 字节 + * - Padding:PKCS#7(手动处理,关闭自动 padding) + * - 明文结构: + * + * ``` + * | random(16) | msg_len(4) | msg | appid | padding | + * ``` + * + * @see 微信官方文档: https://developers.weixin.qq.com/doc/service/guide/dev/push/encryption.html */ export class WechatCrypto { + /** + * AES 密钥(32 字节) + * + * 由 EncodingAESKey(43 位 Base64 字符串)补 `=` 后解码得到 + */ AESKey: Buffer; + + /** + * 初始化向量(IV) + * + * 微信协议中固定为 AESKey 的前 16 字节 + */ iv: Buffer; + + /** + * 当前公众号 / 企业微信的 AppID + * + * 用于解密后校验消息是否属于当前应用 + */ AppId: string; + /** + * 构造加解密实例 + * + * @param encodingAESKey 微信后台提供的 EncodingAESKey(不包含末尾的 `=`) + * @param appId 微信 AppID / CorpID + */ constructor(encodingAESKey: string, appId: string) { + // EncodingAESKey 长度为 43,需要补一个 '=' 才能正确 Base64 解码 this.AESKey = Buffer.from(encodingAESKey + '=', 'base64'); + // 微信协议规定 IV = AESKey 前 16 字节 this.iv = this.AESKey.subarray(0, 16); this.AppId = appId; } - /** 信息体解密 */ - decrypt(encrypt: string) { + /** + * 解密微信服务器发送的加密消息体 + * + * @param encrypt Base64 编码的 Encrypt 字段 + * @returns 解密后的 XML / JSON 明文字符串 + * + * @throws {Error} AppID 不匹配时抛出异常(防止消息伪造) + */ + decrypt(encrypt: string): string { + // Base64 解码后的密文 const TmpMsg = Buffer.from(encrypt, 'base64'); + + // 创建AES-256-CBC解密器 const decipher = crypto.createDecipheriv('aes-256-cbc', this.AESKey, this.iv); + + // 微信使用 PKCS#7, 关闭自动 padding,改为手动处理 decipher.setAutoPadding(false); + + // 解密得到原始字节流 const decrypted = Buffer.concat([ decipher.update(TmpMsg), decipher.final(), ]); - let pad = decrypted[decrypted.length - 1]!; - if (pad < 1 || pad > 32) pad = 0; - const content = decrypted.subarray(16, decrypted.length - pad); - const msgLength = content.readUInt32BE(0); - const msg = content.subarray(4, 4 + msgLength).toString('utf-8'); - const appId = content.subarray(4 + msgLength).toString('utf-8'); + // 去除 PKCS#7 padding + const content = this.pkcs7Unpad(decrypted); + // 读取消息体长度(4 字节大端) + const msgLength = content.readUInt32BE(0); + + // 提取消息正文 + const msg = content.subarray(4, 4 + msgLength).toString('utf-8'); + + // 提取并校验 AppID + const appId = content.subarray(4 + msgLength).toString('utf-8'); if (appId !== this.AppId) { throw new Error('AppID mismatch'); } @@ -38,22 +99,67 @@ export class WechatCrypto { return msg; } - /** 信息体加密 */ - encrypt(replyMsg: string) { + /** + * 加密被动回复消息 + * + * @param replyMsg 待加密的回复消息(XML / JSON 字符串) + * @returns Base64 编码后的 Encrypt 字段 + */ + encrypt(replyMsg: string): string { + // 生成 16 字节随机数 const random16 = crypto.randomBytes(16); const msg = Buffer.from(replyMsg); + + // 消息长度(4 字节大端) const msgLength = Buffer.alloc(4); msgLength.writeUInt32BE(msg.length, 0); const appIdBuffer = Buffer.from(this.AppId); + // 按微信协议拼装明文 const raw = Buffer.concat([random16, msgLength, msg, appIdBuffer]); - const padLen = 32 - (raw.length % 32); - const pad = Buffer.alloc(padLen, padLen); - const content = Buffer.concat([raw, pad]); + // 添加 PKCS#7 padding + const content = this.pkcs7Pad(raw); + + // 创建 AES 加密器 const cipher = crypto.createCipheriv('aes-256-cbc', this.AESKey, this.iv); cipher.setAutoPadding(false); + const encrypted = Buffer.concat([cipher.update(content), cipher.final()]); + + // 微信要求最终结果为 Base64 return encrypted.toString('base64'); } + + /** + * PKCS#7 去除padding + * + * 最后一个字节表示 padding 长度(1 ~ 32) + * + * @param buf 待去除padding的Buffer + * @returns 去除padding后的Buffer + */ + private pkcs7Unpad(buf: Buffer): Buffer { + const pad = buf[buf.length - 1]!; + if (pad < 1 || pad > 32) { + return buf; + } + + // 去除padding,并跳过前16字节随机数 + return buf.subarray(16, buf.length - pad); + } + + /** + * PKCS#7 添加padding + * + * 块大小固定32字节 + * + * @param buf 待padding的Buffer + * @returns padding后的Buffer + */ + private pkcs7Pad(buf: Buffer): Buffer { + const padLen = 32 - (buf.length % 32); + const pad = Buffer.alloc(padLen, padLen); + return Buffer.concat([buf, pad]); + } } \ No newline at end of file diff --git a/src/utils/wechatXmlParser.ts b/src/utils/wechatXmlParser.ts new file mode 100644 index 0000000..093a554 --- /dev/null +++ b/src/utils/wechatXmlParser.ts @@ -0,0 +1,310 @@ +import { XMLParser } from "fast-xml-parser"; +import getRawBody from "raw-body"; +import type { Request } from "express"; +import type { WechatReceivedBaseMessage, WechatReceivedImageMessage, WechatReceivedLinkMessage, WechatReceivedLocationMessage, WechatReceivedMessage, WechatReceivedTextMessage, WechatReceivedVideoMessage, WechatReceivedVoiceMessage } from "../types/received-message"; +import type { WechatEncryptMessage } from "../types/encrypted-message"; +import { WechatXmlObject, NormalizedWechatXml } from "../types/wechat-xml"; +import { assertString, assertWechatReceivedMessageType } from "./assertUtils"; + +/** + * 从原始XML字符串解析为微信XML对象 + * + * @param raw 原始XML字符串 + * @returns 解析后的微信XML对象 + * + * @example + * ```ts + * const rawXml = ` + * + * + * + * 1348831860 + * + * + * + * `; + * + * const xmlObj = parseRawXml(rawXml); + * // xmlObj: + * // { + * // ToUserName: { __cdata: "toUser" }, + * // FromUserName: { __cdata: "fromUser" }, + * // CreateTime: "1348831860", + * // MsgType: { __cdata: "text" }, + * // Content: { __cdata: "this is a test" } + * // } + * ``` + */ +function parseRawXml(raw: string): WechatXmlObject { + const parser = new XMLParser({ + ignoreAttributes: false, + parseTagValue: false, + cdataPropName: '__cdata', + trimValues: true, + }); + const result = parser.parse(raw); + + if (!result?.xml || typeof result.xml !== 'object') { + throw new Error('Invalid XML payload'); + } + + return result.xml as WechatXmlObject; +} + +/** + * 解析微信返回的XML,提取CDATA内容 + * + * 解析CDATA节点内容,如果不存在CDATA则返回原始值。 + * XML解析器会将带有CDATA的节点解析为对象形式,如 { __cdata: "value" }。 + * 本方法提取该内容,返回纯字符串形式的对象。 + * + * @param xml 原始XML对象 + * @returns 解析后的对象 + * + * @example + * ```ts + * const xml = { + * ToUserName: { __cdata: "toUser" }, + * FromUserName: "fromUser", + * Content: { __cdata: "Hello" } + * }; + * + * const normalized = normalizeWechatXml(xml); + * // normalized: + * // { + * // ToUserName: "toUser", + * // FromUserName: "fromUser", + * // Content: "Hello" + * // } + * ``` + */ +function normalizeWechatXml(xml: T): NormalizedWechatXml { + const result = {} as NormalizedWechatXml; + + for (const key in xml) { + if (!Object.prototype.hasOwnProperty.call(xml, key)) { + continue; + } + + const value = xml[key]; + + if (typeof value === 'object' && value && '__cdata' in value) { + result[key] = value.__cdata ?? ''; + } else if (typeof value === 'string') { + result[key] = value as string; + } else { + result[key] = undefined; + } + } + + return result; +} + +/** + * 将请求体解析为加密消息结构 + * + * 微信公众号/服务号发送的原生加密信息为XML格式。 + * 本方法将该XML解析为 {@link WechatEncryptMessage} 类型的对象。 + + * + * @param req 原始请求对象 + * @returns 解析后的加密消息对象 + * + * @example + * 微信加密消息结构示例: + * ```xml + * + * + * + * + * ``` + * + * 解析后得到对象: + * ```ts + * { + * ToUserName: "toUser", + * Encrypt: "msg_encrypt" + * } + * ``` + */ +export async function parseWechatEncryptMessage(req: Request): Promise { + const raw = await getRawBody(req, { encoding: "utf-8" }); + + // const parser = new XMLParser({ + // ignoreAttributes: false, + // parseTagValue: false, + // cdataPropName: '__cdata', + // trimValues: true, + // }) + // const result = parser.parse(raw); + + // if (!result?.xml || typeof result.xml !== 'object') { + // throw new Error('Invalid XML payload'); + // } + + // const xml = result.xml as WechatXmlObject; + + const xml = parseRawXml(raw); + + const normalized = normalizeWechatXml(xml); + + const { ToUserName, Encrypt } = normalized; + + if (!ToUserName || typeof ToUserName !== 'string') { + throw new Error('Missing ToUserName in encrypted message'); + } + + if (!Encrypt || typeof Encrypt !== 'string') { + throw new Error('Missing Encrypt in encrypted message'); + } + + return { + ToUserName, + Encrypt, + } satisfies WechatEncryptMessage; +} + +/** + * 解析解密后的XML信息体 + * + * 解密微信服务器发送的加密信息后,得到字符串格式的XML信息体。 + * 本方法将该XML解析为 {@link WechatReceivedMessage} 类型的对象 + * + * @param raw 解密后的XML字符串 + * @returns 解析后的微信消息对象 + * + * @exmpale + * + * ```ts + * const decryptedXml = ` + * + * + * + * 1348831860 + * + * + * + * `; + * + * const message = await parseDecryptedXML(decryptedXml); + * // message: + * // { + * // ToUserName: "toUser", + * // FromUserName: "fromUser", + * // CreateTime: 1348831860, + * // MsgType: "text", + * // Content: "this is a test" + * // } + * ``` + */ +export async function parseDecryptedXML(raw: string): Promise { + // const parser = new XMLParser({ + // ignoreAttributes: false, + // parseTagValue: false, + // cdataPropName: '__cdata', + // trimValues: true, + // }); + // const result = parser.parse(raw); + + // const xml = result.xml as WechatXmlObject; + + const xml = parseRawXml(raw); + + // 将可能存在的 __cdata 提取 + const normalized = normalizeWechatXml(xml); + + const msgType = normalized.MsgType; + assertString(msgType, 'MsgType'); + assertWechatReceivedMessageType(msgType, 'MsgType'); + + // 基础字段类型断言 + assertString(normalized.ToUserName, 'ToUserName'); + assertString(normalized.FromUserName, 'FromUserName'); + assertString(normalized.CreateTime, 'CreateTime'); + assertString(normalized.MsgId, 'MsgId'); + + const baseFields: Omit = { + ToUserName: normalized.ToUserName, + FromUserName: normalized.FromUserName, + CreateTime: Number(normalized.CreateTime), + MsgId: normalized.MsgId, + MsgDataId: normalized.MsgDataId, + Idx: normalized.Idx, + }; + + // 根据 MsgType 进行具体消息类型的构建 + switch (msgType) { + case "text": + assertString(normalized.Content, 'Content'); + return { + ...baseFields, + MsgType: "text", + Content: normalized.Content, + } satisfies WechatReceivedTextMessage; + case "image": + assertString(normalized.PicUrl, 'PicUrl'); + assertString(normalized.MediaId, 'MediaId'); + return { + ...baseFields, + MsgType: "image", + PicUrl: normalized.PicUrl, + MediaId: normalized.MediaId, + } satisfies WechatReceivedImageMessage; + case "voice": + assertString(normalized.MediaId, 'MediaId'); + assertString(normalized.Format, 'Format'); + assertString(normalized.MediaId16K, 'MediaId16K'); + return { + ...baseFields, + MsgType: "voice", + MediaId: normalized.MediaId, + Format: normalized.Format, + MediaId16K: normalized.MediaId16K, + } satisfies WechatReceivedVoiceMessage; + case "video": + assertString(normalized.MediaId, 'MediaId'); + assertString(normalized.ThumbMediaId, 'ThumbMediaId'); + return { + ...baseFields, + MsgType: "video", + MediaId: normalized.MediaId, + ThumbMediaId: normalized.ThumbMediaId, + } satisfies WechatReceivedVideoMessage; + case "shortvideo": + assertString(normalized.MediaId, 'MediaId'); + assertString(normalized.ThumbMediaId, 'ThumbMediaId'); + return { + ...baseFields, + MsgType: "shortvideo", + MediaId: normalized.MediaId, + ThumbMediaId: normalized.ThumbMediaId, + } satisfies WechatReceivedVideoMessage; + case "location": + assertString(normalized.Location_X, 'Location_X'); + assertString(normalized.Location_Y, 'Location_Y'); + assertString(normalized.Scale, 'Scale'); + assertString(normalized.Label, 'Label'); + return { + ...baseFields, + MsgType: "location", + Location_X: normalized.Location_X, + Location_Y: normalized.Location_Y, + Scale: normalized.Scale, + Label: normalized.Label, + } satisfies WechatReceivedLocationMessage; + case "link": + assertString(normalized.Title, 'Title'); + assertString(normalized.Description, 'Description'); + assertString(normalized.Url, 'Url'); + return { + ...baseFields, + MsgType: "link", + Title: normalized.Title, + Description: normalized.Description, + Url: normalized.Url, + } satisfies WechatReceivedLinkMessage; + default: + const _exhaustiveCheck: never = msgType; + throw new Error(`Unsupported MsgType: ${_exhaustiveCheck}`); + } +} \ No newline at end of file diff --git a/src/utils/xml-parser.ts b/src/utils/xml-parser.ts deleted file mode 100644 index d3085f0..0000000 --- a/src/utils/xml-parser.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { XMLParser } from "fast-xml-parser"; -import getRawBody from "raw-body"; -import type { Request, Response } from "express"; -import { WechatEncryptMessage, WechatMessage } from "../types/wechat-message"; - -export async function parseWechatEncrypt(req: Request): Promise { - const raw = await getRawBody(req, { encoding: "utf-8" }); - - const parser = new XMLParser({ - ignoreAttributes: false, - cdataPropName: '__cdata' - }) - const result = parser.parse(raw.toString()); - - const xml = result.xml as Record; - - // 将可能存在的 __cdata 提取 - const normalized: Record = {}; - for (const key in xml) { - const value = xml[key]; - if (typeof value === 'object' && value && '__cdata' in value) { - normalized[key] = (value as { __cdata: string }).__cdata; - } else { - normalized[key] = value as string; - } - } - - return { - ToUserName: normalized.ToUserName, - ...normalized, - } as WechatEncryptMessage; -} - -export async function parseStringXML(raw: string): Promise { - const parser = new XMLParser({ - ignoreAttributes: false, - cdataPropName: '__cdata', - }); - const result = parser.parse(raw.toString()); - - const xml = result.xml as Record; - - console.log("Parsed XML:", xml); - - // 将可能存在的 __cdata 提取 - const normalized: Record = {}; - for (const key in xml) { - const value = xml[key]; - if (typeof value === 'object' && value && '__cdata' in value) { - normalized[key] = (value as { __cdata: string }).__cdata; - } else { - normalized[key] = value as string; - } - } - - console.log(normalized.CreateTime) - - return { - ToUserName: normalized.ToUserName, - FromUserName: normalized.FromUserName, - CreateTime: Number(normalized.CreateTime), - MsgType: normalized.MsgType, - ...normalized, - } as WechatMessage; -} - -export async function parseWechatMessage( - req: Request, -): Promise { - const raw = await getRawBody(req, { encoding: "utf-8" }); - - const parser = new XMLParser({ - ignoreAttributes: false, - cdataPropName: '__cdata', - }); - const result = parser.parse(raw.toString()); - - const xml = result.xml as Record; - - console.log("Parsed XML:", xml); - - // 将可能存在的 __cdata 提取 - const normalized: Record = {}; - for (const key in xml) { - const value = xml[key]; - if (typeof value === 'object' && value && '__cdata' in value) { - normalized[key] = (value as { __cdata: string }).__cdata; - } else { - normalized[key] = value as string; - } - } - - console.log(normalized.CreateTime) - - return { - ToUserName: normalized.ToUserName, - FromUserName: normalized.FromUserName, - CreateTime: Number(normalized.CreateTime), - MsgType: normalized.MsgType, - ...normalized, - } as WechatMessage; - -}