Compare commits

...

2 Commits

Author SHA1 Message Date
9b25ee417b feat: 微信公众号/服务号 接收普通信息
- 类型定义: 微信XML结构,微信接收信息类型定义
- 文档补全: 补全各个方法的文档注释
2025-12-12 09:54:12 +00:00
3b73e6cde3 chore: 微信服务号接收消息类型定义 2025-12-12 07:20:29 +00:00
9 changed files with 704 additions and 131 deletions

View File

@ -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 = `<xml>

View File

@ -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;
}

View File

@ -0,0 +1,146 @@
/**
* 微信公众号/服务号「接收普通消息」类型定义
*
* @remarks
* - 对应微信官方XML消息结构
* - 所有消息类型继承自 {@link WechatReceivedBaseMessage}
* - 通过 {@link MsgType} 字段区分不同消息类型
* @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";
/**
* 「接收普通消息」基础类型定义
*
* @remarks
* 所有消息类型均包含这些公共字段
*/
export interface WechatReceivedBaseMessage {
/** 开发者微信号 */
ToUserName: string;
/** 发送方帐号一个OpenID */
FromUserName: string;
/** 消息创建时间 (整型) */
CreateTime: number;
/** 消息类型
*
* @remarks
* 在具体消息类型中收窄为字面量类型(如"text","image"等)
*/
MsgType: WechatReceivedMessageType;
/** 消息id64位整型 */
MsgId: string;
/** 消息数据id,消息来自文章时存在 */
MsgDataId?: string;
/** 多图文时的文章索引从1开始消息来自文章时存在 */
Idx?: string;
}
/**
* 「接收普通消息」文本消息
*/
export interface WechatReceivedTextMessage extends WechatReceivedBaseMessage {
/** 文本消息类型 */
MsgType: "text";
/** 文本消息内容 */
Content: string;
}
/**
* 「接收普通消息」图片消息
*/
export interface WechatReceivedImageMessage extends WechatReceivedBaseMessage {
/** 图片消息类型 */
MsgType: "image";
/** 图片链接 */
PicUrl: string;
/** 图片消息媒体id可以调用多媒体文件下载接口拉取数据。 */
MediaId: string;
}
/**
* 「接收普通消息」语音消息
*/
export interface WechatReceivedVoiceMessage extends WechatReceivedBaseMessage {
/** 语音消息类型 */
MsgType: "voice";
/** 语音消息媒体id可以调用多媒体文件下载接口拉取数据。 */
MediaId: string;
/** 语音格式如amrspeex等 */
Format: string;
/** 16K采样率语音信息媒体id, 可以调用获取临时素材接口拉取数据返回16K采样率amr/speex语音。 */
MediaId16K: string;
}
/**
* 「接收普通消息」视频消息
*/
export interface WechatReceivedVideoMessage extends WechatReceivedBaseMessage {
/** 视频消息类型,兼容普通视频与短视频 */
MsgType: "video" | "shortvideo";
/** 视频消息媒体id可以调用多媒体文件下载接口拉取数据。 */
MediaId: string;
/** 视频消息缩略图的媒体id可以调用多媒体文件下载接口拉取数据。 */
ThumbMediaId: string;
}
/**
* 「接收普通消息」地理位置消息
*/
export interface WechatReceivedLocationMessage extends WechatReceivedBaseMessage {
/** 地理位置消息类型 */
MsgType: "location";
/** 地理位置维度 */
Location_X: string;
/** 地理位置经度 */
Location_Y: string;
/** 地图缩放大小 */
Scale: string;
/** 地理位置信息 */
Label: string;
}
/**
* 「接收普通消息」链接消息
*/
export interface WechatReceivedLinkMessage extends WechatReceivedBaseMessage {
/** 链接消息类型 */
MsgType: "link";
/** 消息标题 */
Title: string;
/** 消息描述 */
Description: string;
/** 消息链接 */
Url: string;
}
/**
* 「接收普通消息」消息联合类型
*
* @remarks
* 可通过MsgType进行类型收窄
*/
export type WechatReceivedMessage =
| WechatReceivedTextMessage
| WechatReceivedImageMessage
| WechatReceivedVoiceMessage
| WechatReceivedVideoMessage
| WechatReceivedLocationMessage
| WechatReceivedLinkMessage;

32
src/types/wechat-xml.ts Normal file
View File

@ -0,0 +1,32 @@
/**
* 微信XML CDATA节点
*/
export interface WechatXmlCdataNode {
__cdata?: string;
}
/**
* 微信XML值类型定义
*/
export type WechatXmlValue =
| string
| WechatXmlCdataNode;
/**
* 微信XML对象类型定义
*/
export type WechatXmlObject = Record<string, WechatXmlValue>;
/**
* 微信XML归一化对象值类型定义
*/
type WechatNormalizedValue =
| string
| undefined;
/**
* 微信XML归一化对象
*/
export type NormalizedWechatXml<T extends Record<string, unknown>> = {
[K in keyof T]: WechatNormalizedValue;
};

33
src/utils/assertUtils.ts Normal file
View File

@ -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}`);
}
}

View File

@ -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('');

View File

@ -1,36 +1,97 @@
import crypto from 'crypto';
/**
* 微信AES加解密工具
* 微信消息 AES-256-CBC 加解密工具
*
* #### 适用场景
* - 微信公众号 / 企业微信 **消息体加解密**
* - 微信服务器推送的 `Encrypt` 字段解密
* - 被动回复消息体的加密
*
* #### 加解密规范(微信官方)
* - 算法AES-256-CBC
* - Key由 `EncodingAESKey` Base64 解码得到32 字节)
* - IVAESKey 前 16 字节
* - PaddingPKCS#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 字节)
*
* 由 EncodingAESKey43 位 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]);
}
}

View File

@ -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 = `
* <xml>
* <ToUserName><![CDATA[toUser]]></ToUserName>
* <FromUserName><![CDATA[fromUser]]></FromUserName>
* <CreateTime>1348831860</CreateTime>
* <MsgType><![CDATA[text]]></MsgType>
* <Content><![CDATA[this is a test]]></Content>
* </xml>
* `;
*
* 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<T extends WechatXmlObject>(xml: T): NormalizedWechatXml<T> {
const result = {} as NormalizedWechatXml<T>;
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
* <xml>
* <ToUserName><![CDATA[toUser]]></ToUserName>
* <Encrypt><![CDATA[msg_encrypt]]></Encrypt>
* </xml>
* ```
*
* 解析后得到对象:
* ```ts
* {
* ToUserName: "toUser",
* Encrypt: "msg_encrypt"
* }
* ```
*/
export async function parseWechatEncryptMessage(req: Request): Promise<WechatEncryptMessage> {
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 = `
* <xml>
* <ToUserName><![CDATA[toUser]]></ToUserName>
* <FromUserName><![CDATA[fromUser]]></FromUserName>
* <CreateTime>1348831860</CreateTime>
* <MsgType><![CDATA[text]]></MsgType>
* <Content><![CDATA[this is a test]]></Content>
* </xml>
* `;
*
* 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<WechatReceivedMessage> {
// 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<WechatReceivedBaseMessage, 'MsgType'> = {
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}`);
}
}

View File

@ -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<WechatEncryptMessage> {
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<string, unknown>;
// 将可能存在的 __cdata 提取
const normalized: Record<string, string> = {};
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<WechatMessage> {
const parser = new XMLParser({
ignoreAttributes: false,
cdataPropName: '__cdata',
});
const result = parser.parse(raw.toString());
const xml = result.xml as Record<string, unknown>;
console.log("Parsed XML:", xml);
// 将可能存在的 __cdata 提取
const normalized: Record<string, string> = {};
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<WechatMessage> {
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<string, unknown>;
console.log("Parsed XML:", xml);
// 将可能存在的 __cdata 提取
const normalized: Record<string, string> = {};
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;
}