Compare commits
8 Commits
d8074ea6d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 545d1e687f | |||
| ac4cea4658 | |||
| fd74fc0f2d | |||
| 0d0699f40c | |||
| 9f5157c5e1 | |||
| 948e7bf109 | |||
| 9b25ee417b | |||
| 3b73e6cde3 |
158
src/index.ts
158
src/index.ts
@ -1,12 +1,16 @@
|
||||
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";
|
||||
import { Question, WechatReply } from "./types/directus-schema";
|
||||
import { WechatReplyBuilder } from "./utils/wechatReplyBuilder";
|
||||
import { WechatEncryptBuilder } from "./utils/wechatEncryptBuilder";
|
||||
|
||||
export default defineEndpoint({
|
||||
id: "wechat-service",
|
||||
handler: (router, { env }) => {
|
||||
handler: (router, { env, services, getSchema }) => {
|
||||
const { ItemsService, CollectionsService } = services;
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { signature, timestamp, nonce, echostr } = req.query;
|
||||
|
||||
@ -18,17 +22,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,36 +40,131 @@ export default defineEndpoint({
|
||||
const appId = env.WECHAT_APPID;
|
||||
const cryptoUtil = new WechatCrypto(encodingAESKey, appId);
|
||||
const decryptXml = cryptoUtil.decrypt(encrypt);
|
||||
const msg = await parseStringXML(decryptXml);
|
||||
let replyContent;
|
||||
if (msg.MsgType !== 'text') {
|
||||
replyContent = "暂不支持该消息类型";
|
||||
} else {
|
||||
replyContent = `已收到你的加密消息:${msg.Content}`;
|
||||
const msg = await parseDecryptedXML(decryptXml);
|
||||
|
||||
const { ToUserName, FromUserName, MsgType } = msg;
|
||||
const replyBuilder = WechatReplyBuilder.fromOptions({
|
||||
toUserName: FromUserName,
|
||||
fromUserName: ToUserName,
|
||||
});
|
||||
|
||||
let replyContent;
|
||||
let reply;
|
||||
|
||||
if(MsgType === 'event') {
|
||||
// 不处理事件推送,回复空内容以避免重试
|
||||
console.warn("Received event push, no reply sent.");
|
||||
res.set('Content-Type', 'plain/text');
|
||||
res.send('');
|
||||
return;
|
||||
}
|
||||
|
||||
// 回复消息(示例:文本)
|
||||
const reply = `<xml>
|
||||
<ToUserName><![CDATA[${msg.FromUserName}]]></ToUserName>
|
||||
<FromUserName><![CDATA[${msg.ToUserName}]]></FromUserName>
|
||||
<CreateTime>${Date.now()}</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[${replyContent}]]></Content>
|
||||
</xml>`;
|
||||
if (MsgType !== 'text') {
|
||||
replyContent = "暂不支持该消息类型";
|
||||
} else {
|
||||
// 检查微信回复集合是否存在
|
||||
const replyCollectionService = new CollectionsService({ schema: await getSchema() });
|
||||
try {
|
||||
await replyCollectionService.readOne('wechat_replies');
|
||||
} catch (err) {
|
||||
// 集合不存在,向微信服务器发送空回复以避免重试
|
||||
console.warn("wechat_replies collection does not exist.");
|
||||
|
||||
res.set('Content-Type', 'plain/text');
|
||||
res.send('');
|
||||
|
||||
throw Error("请先创建 wechat_replies 集合,用于存储自动回复内容");
|
||||
}
|
||||
|
||||
// 从 Directus 中查询匹配的回复
|
||||
const replyService = new ItemsService<WechatReply>('wechat_replies', { schema: await getSchema() });
|
||||
|
||||
const replies = await replyService.readByQuery({
|
||||
fields: ['reply.item.*', 'reply.collection'],
|
||||
filter: {
|
||||
keyword: {
|
||||
_eq: msg.Content
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
});
|
||||
|
||||
if (replies.length === 0) {
|
||||
replyContent = "未找到匹配的回复内容";
|
||||
} else {
|
||||
const directusReply = replies[0]?.reply;
|
||||
if (!directusReply || directusReply.length === 0) {
|
||||
replyContent = "未找到匹配的回复内容";
|
||||
}
|
||||
const firstReply = directusReply?.[0];
|
||||
if (firstReply && firstReply.collection === 'wechat_text_replies') {
|
||||
const textItem = firstReply.item as { content: string };
|
||||
reply = replyBuilder.buildTextReply(textItem.content);
|
||||
} else if (firstReply && firstReply.collection === 'wechat_news_replies') {
|
||||
const linkItem = firstReply.item as unknown as { title: string; description: string; url: string; thumbnail: string; enable_question: boolean; related_question?: string; };
|
||||
if (!linkItem.enable_question) {
|
||||
// 构造图文消息XML
|
||||
reply = replyBuilder.buildNewsReply(
|
||||
linkItem.title,
|
||||
linkItem.description,
|
||||
linkItem.thumbnail,
|
||||
linkItem.url
|
||||
);
|
||||
} else {
|
||||
const questionId = linkItem.related_question ?? undefined;
|
||||
if (!questionId) {
|
||||
replyContent = "未找到匹配的回复内容";
|
||||
} else {
|
||||
// 查询相关问题
|
||||
const questionService = new ItemsService<Question>('questions', { schema: await getSchema() });
|
||||
try {
|
||||
const question = await questionService.readOne(questionId, {
|
||||
fields: ['translations.*'],
|
||||
});
|
||||
// 查询翻译内容,优先中文
|
||||
const translation = question.translations.find(t => t.languages_code === 'zh-CN') || question.translations[0];
|
||||
if (!translation) {
|
||||
throw new Error("未找到问题翻译内容");
|
||||
}
|
||||
|
||||
const baseUrl = env.WECHAT_WEBSITE_URL ?? undefined;
|
||||
|
||||
// 构造图文消息XML
|
||||
reply = replyBuilder.buildNewsReply(
|
||||
'常见问题',
|
||||
translation.title,
|
||||
baseUrl ? `${baseUrl}/api/assets/${linkItem.thumbnail}` : linkItem.thumbnail,
|
||||
baseUrl ? `${baseUrl}/support/faq?focus=${questionId}` : linkItem.url,
|
||||
);
|
||||
console.log(reply);
|
||||
|
||||
} catch (err) {
|
||||
replyContent = "未找到匹配的回复内容";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
replyContent = "未找到匹配的回复内容";
|
||||
reply = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
reply = replyBuilder.buildTextReply('未找到匹配的回复内容');
|
||||
}
|
||||
|
||||
const encryptedReply = cryptoUtil.encrypt(reply);
|
||||
|
||||
// 再次签名
|
||||
const replyArr = [token, timestamp, nonce, encryptedReply].sort();
|
||||
const replySig = crypto.createHash('sha1').update(replyArr.join('')).digest('hex');
|
||||
const encryptBuilder = new WechatEncryptBuilder({
|
||||
token: token as string,
|
||||
timestamp: Date.now().toString(),
|
||||
nonce: nonce as string,
|
||||
})
|
||||
|
||||
const responseXml = `<xml>
|
||||
<Encrypt><![CDATA[${encryptedReply}]]></Encrypt>
|
||||
<MsgSignature><![CDATA[${replySig}]]></MsgSignature>
|
||||
<TimeStamp>${timestamp}</TimeStamp>
|
||||
<Nonce><![CDATA[${nonce}]]></Nonce>
|
||||
</xml>`;
|
||||
const responseXml = encryptBuilder.buildResponse(encryptedReply);
|
||||
|
||||
res.set('Content-Type', 'application/xml');
|
||||
return res.send(responseXml);
|
||||
|
||||
63
src/types/directus-schema.ts
Normal file
63
src/types/directus-schema.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 相关问题类型
|
||||
*/
|
||||
export interface Question {
|
||||
/** 唯一标识符 */
|
||||
id: string;
|
||||
|
||||
translations: QuestionTranslation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 相关问题翻译类型
|
||||
*/
|
||||
export interface QuestionTranslation {
|
||||
/** 唯一标识符 */
|
||||
id: string;
|
||||
/** 语言代码 */
|
||||
languages_code: string;
|
||||
/** 问题标题 */
|
||||
title: string;
|
||||
/** 问题内容 */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回复消息类型——文本消息
|
||||
*/
|
||||
export interface WechatTextReply {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回复消息类型——图文消息
|
||||
*/
|
||||
export interface WechatNewsReply {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
enable_question: boolean;
|
||||
related_question?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回复消息关联项
|
||||
*/
|
||||
export interface WechatRepliesReply {
|
||||
id: string;
|
||||
wechat_replies_id: string;
|
||||
item: WechatTextReply | WechatNewsReply;
|
||||
collection: 'wechat_text_replies' | 'wechat_news_replies';
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回复消息类型——回复集合
|
||||
*/
|
||||
export interface WechatReply {
|
||||
id: string;
|
||||
keyword: string;
|
||||
reply: WechatRepliesReply[];
|
||||
}
|
||||
18
src/types/encrypted-message.ts
Normal file
18
src/types/encrypted-message.ts
Normal 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;
|
||||
}
|
||||
114
src/types/received-event.ts
Normal file
114
src/types/received-event.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 微信公众号/服务号「接收事件推送」类型定义
|
||||
*
|
||||
* @remarks
|
||||
* - 对应微信官方XML信息结构
|
||||
* - 所有事件类型继承自 {@link WechatReceivedBaseEvent}
|
||||
* - 通过 {@link Event} 字段区分不同事件类型
|
||||
* @see 微信官方文档: https://developers.weixin.qq.com/doc/service/guide/product/message/Receiving_event_pushes.html
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
/**
|
||||
* 「接收事件推送」事件类型字面量联合类型
|
||||
*
|
||||
* @remarks
|
||||
* 可用于类型收窄
|
||||
*/
|
||||
export type WechatReceivedEventType = | "subscribe"
|
||||
| "unsubscribe"
|
||||
| "SCAN"
|
||||
| "LOCATION"
|
||||
| "CLICK"
|
||||
| "VIEW"
|
||||
|
||||
/**
|
||||
* 「接收事件推送」基础类型定义
|
||||
*
|
||||
* @remarks
|
||||
* 所有事件类型均包含这些公共字段
|
||||
*/
|
||||
export interface WechatReceivedBaseEvent {
|
||||
/** 开发者微信号 */
|
||||
ToUserName: string;
|
||||
/** 发送方帐号(一个OpenID) */
|
||||
FromUserName: string;
|
||||
/** 消息创建时间 (整型) */
|
||||
CreateTime: number;
|
||||
/** 消息类型,event */
|
||||
MsgType: "event";
|
||||
/** 事件类型
|
||||
*
|
||||
* @remarks
|
||||
* 在具体事件类型中收窄为字面量类型(如"subscribe","CLICK"等)
|
||||
*/
|
||||
Event: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「接收事件推送」用户未关注时,进行关注后的事件推送
|
||||
*/
|
||||
export interface WechatReceivedSubscribeEvent extends WechatReceivedBaseEvent {
|
||||
/** 事件类型: subscribe */
|
||||
Event: "subscribe";
|
||||
/** 事件KEY值,qrscene_为前缀,后面为二维码的参数值 */
|
||||
EventKey?: string;
|
||||
/** 二维码的ticket,可用来换取二维码图片 */
|
||||
Ticket?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「接收事件推送」用户已关注时的事件推送
|
||||
*/
|
||||
export interface WechatReceivedScanEvent extends WechatReceivedBaseEvent {
|
||||
/** 事件类型: SCAN */
|
||||
Event: "SCAN";
|
||||
/** 事件KEY值,是一个32位无符号整数,即创建二维码时的二维码scene_id */
|
||||
EventKey: string;
|
||||
/** 二维码的ticket,可用来换取二维码图片 */
|
||||
Ticket: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「接收事件推送」上报地理位置事件
|
||||
*/
|
||||
export interface WechatReceivedLocationEvent extends WechatReceivedBaseEvent {
|
||||
/** 事件类型: LOCATION */
|
||||
Event: "LOCATION";
|
||||
/** 地理位置纬度 */
|
||||
Latitude: string;
|
||||
/** 地理位置经度 */
|
||||
Longitude: string;
|
||||
/** 地理位置精度 */
|
||||
Precision: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「接收事件推送」自定义菜单点击事件
|
||||
*/
|
||||
export interface WechatReceivedClickEvent extends WechatReceivedBaseEvent {
|
||||
/** 事件类型: CLICK */
|
||||
Event: "CLICK";
|
||||
/** 事件KEY值,与自定义菜单接口中KEY值对应 */
|
||||
EventKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「接收事件推送」自定义菜单跳转链接事件
|
||||
*/
|
||||
export interface WechatReceivedViewEvent extends WechatReceivedBaseEvent {
|
||||
/** 事件类型: VIEW */
|
||||
Event: "VIEW";
|
||||
/** 事件KEY值,设置的跳转URL */
|
||||
EventKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「接收事件推送」类型联合类型
|
||||
*/
|
||||
export type WechatReceivedEvent = WechatReceivedSubscribeEvent
|
||||
| WechatReceivedScanEvent
|
||||
| WechatReceivedLocationEvent
|
||||
| WechatReceivedClickEvent
|
||||
| WechatReceivedViewEvent;
|
||||
146
src/types/received-message.ts
Normal file
146
src/types/received-message.ts
Normal 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;
|
||||
/** 消息id,64位整型 */
|
||||
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;
|
||||
/** 语音格式,如amr,speex等 */
|
||||
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;
|
||||
|
||||
152
src/types/reply-message.ts
Normal file
152
src/types/reply-message.ts
Normal file
@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 微信公众号/服务号「被动回复用户消息」类型定义
|
||||
*
|
||||
* @remarks
|
||||
* - 对应微信官方XML消息结构
|
||||
* - 所有消息类型继承自 {@link WechatReplyBaseMessage}
|
||||
* - 通过 {@link MsgType} 字段区分不同消息类型
|
||||
* @see 微信官方文档: https://developers.weixin.qq.com/doc/service/guide/product/message/Passive_user_reply_message.html
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
/**
|
||||
* 「被动回复用户消息」消息类型字面量联合类型
|
||||
*
|
||||
* @remarks
|
||||
* 可用于类型收窄
|
||||
*/
|
||||
export type WechatReplyMessageType =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'voice'
|
||||
| 'video'
|
||||
| 'music'
|
||||
| 'news';
|
||||
|
||||
/**
|
||||
* 「被动回复用户消息」基础类型定义
|
||||
*
|
||||
* @remarks
|
||||
* 所有消息类型均包含这些公共字段
|
||||
*/
|
||||
export interface WechatReplyBaseMessage {
|
||||
/** 接收方帐号(收到的OpenID) */
|
||||
ToUserName: string;
|
||||
/** 发送方帐号(公众号微信号) */
|
||||
FromUserName: string;
|
||||
/** 消息创建时间 (整型) */
|
||||
CreateTime: number;
|
||||
/** 消息类型
|
||||
*
|
||||
* @remarks
|
||||
* 在具体消息类型中收窄为字面量类型(如"text","image"等)
|
||||
*/
|
||||
MsgType: WechatReplyMessageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「被动回复用户消息」文本消息
|
||||
*/
|
||||
export interface WechatReplyTextMessage extends WechatReplyBaseMessage {
|
||||
/** 文本消息类型 */
|
||||
MsgType: "text";
|
||||
/** 文本消息内容 */
|
||||
Content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「被动回复用户消息」图片消息
|
||||
*/
|
||||
export interface WechatReplyImageMessage extends WechatReplyBaseMessage {
|
||||
/** 图片消息类型 */
|
||||
MsgType: "image";
|
||||
/** 图片消息媒体id */
|
||||
Image: {
|
||||
/** 通过素材管理中的接口上传多媒体文件,得到的id */
|
||||
MediaId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「被动回复用户消息」语音消息
|
||||
*/
|
||||
export interface WechatReplyVoiceMessage extends WechatReplyBaseMessage {
|
||||
/** 语音消息类型 */
|
||||
MsgType: "voice";
|
||||
/** 语音消息媒体id */
|
||||
Voice: {
|
||||
/** 通过素材管理中的接口上传多媒体文件,得到的id */
|
||||
MediaId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「被动回复用户消息」视频消息
|
||||
*/
|
||||
export interface WechatReplyVideoMessage extends WechatReplyBaseMessage {
|
||||
/** 视频消息类型 */
|
||||
MsgType: "video";
|
||||
/** 视频消息媒体id及标题描述 */
|
||||
Video: {
|
||||
/** 通过素材管理中的接口上传多媒体文件,得到的id */
|
||||
MediaId: string;
|
||||
/** 视频消息标题 */
|
||||
Title?: string;
|
||||
/** 视频消息描述 */
|
||||
Description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「被动回复用户消息」音乐消息
|
||||
*/
|
||||
export interface WechatReplyMusicMessage extends WechatReplyBaseMessage {
|
||||
/** 音乐消息类型 */
|
||||
MsgType: "music";
|
||||
/** 音乐消息内容 */
|
||||
Music: {
|
||||
/** 音乐标题 */
|
||||
Title?: string;
|
||||
/** 音乐描述 */
|
||||
Description?: string;
|
||||
/** 音乐链接 */
|
||||
MusicUrl?: string;
|
||||
/** 高质量音乐链接,WIFI环境优先使用该链接播放音乐 */
|
||||
HQMusicUrl?: string;
|
||||
/** 缩略图的媒体id,通过素材管理中的接口上传多媒体文件,得到的id */
|
||||
ThumbMediaId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「被动回复用户消息」图文消息
|
||||
*/
|
||||
export interface WechatReplyNewsMessage extends WechatReplyBaseMessage {
|
||||
/** 图文消息类型 */
|
||||
MsgType: "news";
|
||||
/** 图文消息数量 */
|
||||
ArticleCount: number;
|
||||
/** 图文消息列表 */
|
||||
Articles: {
|
||||
/** 图文消息标题 */
|
||||
Title: string;
|
||||
/** 图文消息描述 */
|
||||
Description: string;
|
||||
/** 图文消息链接 */
|
||||
PicUrl: string;
|
||||
/** 图文消息图片链接 */
|
||||
Url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信公众号/服务号「被动回复用户消息」联合类型
|
||||
*/
|
||||
export type WechatReplyMessage =
|
||||
| WechatReplyTextMessage
|
||||
| WechatReplyImageMessage
|
||||
| WechatReplyVoiceMessage
|
||||
| WechatReplyVideoMessage
|
||||
| WechatReplyMusicMessage
|
||||
| WechatReplyNewsMessage;
|
||||
@ -1,30 +0,0 @@
|
||||
export interface WechatEncryptMessage {
|
||||
ToUserName: string;
|
||||
Encrypt: string;
|
||||
}
|
||||
|
||||
export interface WechatBaseMessage {
|
||||
/** 接收方微信号 */
|
||||
ToUserName: string;
|
||||
/** 发送方微信号 */
|
||||
FromUserName: string;
|
||||
/** 消息创建时间 */
|
||||
CreateTime: number;
|
||||
/** 消息类型 */
|
||||
MsgType: string;
|
||||
}
|
||||
|
||||
export interface WechatTextMessage extends WechatBaseMessage {
|
||||
/** 文本消息类型 */
|
||||
MsgType: "text";
|
||||
/** 文本消息内容 */
|
||||
Content: string;
|
||||
/** 消息id,64位整型 */
|
||||
MsgId: string;
|
||||
/** 消息数据id,消息来自文章时存在 */
|
||||
MsgDataId?: string;
|
||||
/** 多图文时的文章索引,从1开始,消息来自文章时存在 */
|
||||
Idx?: string;
|
||||
}
|
||||
|
||||
export type WechatMessage = WechatTextMessage;
|
||||
32
src/types/wechat-xml.ts
Normal file
32
src/types/wechat-xml.ts
Normal 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;
|
||||
};
|
||||
54
src/utils/assertUtils.ts
Normal file
54
src/utils/assertUtils.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 断言值为字符串类型
|
||||
* @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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const WECHAT_RECEIVED_EVENT_TYPES = [
|
||||
"subscribe",
|
||||
"SCAN",
|
||||
"LOCATION",
|
||||
"CLICK",
|
||||
"VIEW"
|
||||
] as const;
|
||||
|
||||
export type WechatReceivedEventType = typeof WECHAT_RECEIVED_EVENT_TYPES[number];
|
||||
|
||||
/** 断言值为微信接收推送类型字面量
|
||||
*
|
||||
* @param value 原始值
|
||||
* @param fieldName 字段名称
|
||||
*/
|
||||
export function assertWechatReceivedEventType(value: unknown, fieldName: string): asserts value is WechatReceivedEventType {
|
||||
if (typeof value !== 'string' || !WECHAT_RECEIVED_EVENT_TYPES.includes(value as WechatReceivedEventType)) {
|
||||
throw new Error(`Expected ${fieldName} to be one of ${WECHAT_RECEIVED_EVENT_TYPES.join(", ")}, but got ${typeof value === 'string' ? value : typeof value}`);
|
||||
}
|
||||
}
|
||||
@ -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('');
|
||||
|
||||
@ -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 appIdBuffer = Buffer.from(this.AppId, 'utf-8');
|
||||
|
||||
// 按微信协议拼装明文
|
||||
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]);
|
||||
}
|
||||
}
|
||||
59
src/utils/wechatEncryptBuilder.ts
Normal file
59
src/utils/wechatEncryptBuilder.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export interface WechatEncryptBuilderOptions {
|
||||
/** 预设的token,在微信公众平台/服务号后台配置 */
|
||||
token: string;
|
||||
/** 时间戳,使用Date.now()即可 */
|
||||
timestamp: string;
|
||||
/** 随机数,复用微信服务器传递的nonce参数 */
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信加密消息构建器
|
||||
*
|
||||
* 构建复合微信加密消息格式XML字符串,用于回包给微信服务器
|
||||
*
|
||||
* - 对应微信官方XML加密消息结构
|
||||
* @see 微信官方文档: [消息加解密说明](https://developers.weixin.qq.com/doc/service/guide/dev/push/encryption.html)
|
||||
*/
|
||||
export class WechatEncryptBuilder {
|
||||
/** 预设的token,在微信公众平台/服务号后台配置 */
|
||||
private token: string;
|
||||
/** 时间戳,使用Date.now()即可 */
|
||||
private timestamp: string;
|
||||
/** 随机数,复用微信服务器传递的nonce参数 */
|
||||
private nonce: string;
|
||||
|
||||
constructor(options: WechatEncryptBuilderOptions) {
|
||||
const {
|
||||
token,
|
||||
timestamp,
|
||||
nonce,
|
||||
} = options;
|
||||
|
||||
this.token = token;
|
||||
this.timestamp = timestamp;
|
||||
this.nonce = nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建微信加密消息XML Response字符串
|
||||
*
|
||||
* @param encrypt 加密后的消息体(Base64编码的字符串)
|
||||
* @returns 带有加密消息的XML Response字符串
|
||||
*/
|
||||
buildResponse(encrypt: string): string {
|
||||
const tempArray = [this.token, this.timestamp, this.nonce, encrypt].sort();
|
||||
const msgSig = crypto.createHash('sha1').update(tempArray.join('')).digest('hex');
|
||||
|
||||
const responseXml = `<xml>
|
||||
<Encrypt><![CDATA[${encrypt}]]></Encrypt>
|
||||
<MsgSignature><![CDATA[${msgSig}]]></MsgSignature>
|
||||
<TimeStamp>${this.timestamp}</TimeStamp>
|
||||
<Nonce><![CDATA[${this.nonce}]]></Nonce>
|
||||
</xml>`;
|
||||
|
||||
return responseXml;
|
||||
}
|
||||
}
|
||||
105
src/utils/wechatReplyBuilder.ts
Normal file
105
src/utils/wechatReplyBuilder.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { WechatReceivedBaseMessage } from "../types/received-message";
|
||||
|
||||
export interface WechatReplyBuilderOptions {
|
||||
/** 接收方账号(收到的OpenID) */
|
||||
toUserName: string;
|
||||
/** 发送方账号(公众号微信号) */
|
||||
fromUserName: string;
|
||||
|
||||
/** 消息创建时间(整型) */
|
||||
createTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信被动回复消息构造器
|
||||
*
|
||||
* - 微信公众号/服务号被动回复用户消息时使用
|
||||
* - 对应微信官方XML消息结构
|
||||
*
|
||||
* @see 微信官方文档: [被动回复用户消息](https://developers.weixin.qq.com/doc/service/guide/product/message/Passive_user_reply_message.html)
|
||||
*/
|
||||
export class WechatReplyBuilder {
|
||||
/** 接收方账号(收到的OpenID) */
|
||||
private toUserName: string;
|
||||
/** 发送方账号(公众号微信号) */
|
||||
private fromUserName: string
|
||||
/** 消息创建时间(整型) */
|
||||
private createTime: number;
|
||||
|
||||
/**
|
||||
* WechatReplyBuilder 构造函数
|
||||
*
|
||||
* @param options WechatReplyBuilderOptions 配置项
|
||||
*/
|
||||
private constructor(options: WechatReplyBuilderOptions) {
|
||||
const {
|
||||
toUserName,
|
||||
fromUserName,
|
||||
createTime,
|
||||
} = options;
|
||||
|
||||
this.toUserName = toUserName;
|
||||
this.fromUserName = fromUserName;
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
static fromReceivedMessage(msg: WechatReceivedBaseMessage): WechatReplyBuilder {
|
||||
return new WechatReplyBuilder({
|
||||
toUserName: msg.FromUserName,
|
||||
fromUserName: msg.ToUserName,
|
||||
createTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
static fromOptions(options: Omit<WechatReplyBuilderOptions, "createTime"> & {
|
||||
createTime?: number;
|
||||
}): WechatReplyBuilder {
|
||||
return new WechatReplyBuilder({
|
||||
...options,
|
||||
createTime: options.createTime ?? Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造文本消息回复XML
|
||||
*
|
||||
* @param content 文本消息内容
|
||||
* @returns 文本消息回复XML字符串
|
||||
*/
|
||||
buildTextReply(content: string): string {
|
||||
return `<xml>
|
||||
<ToUserName><![CDATA[${this.toUserName}]]></ToUserName>
|
||||
<FromUserName><![CDATA[${this.fromUserName}]]></FromUserName>
|
||||
<CreateTime>${this.createTime}</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[${content}]]></Content>
|
||||
</xml>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造图文消息回复XML
|
||||
*
|
||||
* @param title 图文消息标题
|
||||
* @param description 图文消息描述
|
||||
* @param picUrl 图文消息图片链接
|
||||
* @param url 图文消息跳转链接
|
||||
* @returns 图文消息回复XML字符串
|
||||
*/
|
||||
buildNewsReply(title: string, description: string, picUrl: string, url: string): string {
|
||||
return `<xml>
|
||||
<ToUserName><![CDATA[${this.toUserName}]]></ToUserName>
|
||||
<FromUserName><![CDATA[${this.fromUserName}]]></FromUserName>
|
||||
<CreateTime>${this.createTime}</CreateTime>
|
||||
<MsgType><![CDATA[news]]></MsgType>
|
||||
<ArticleCount>1</ArticleCount>
|
||||
<Articles>
|
||||
<item>
|
||||
<Title><![CDATA[${title}]]></Title>
|
||||
<Description><![CDATA[${description}]]></Description>
|
||||
<PicUrl><![CDATA[${picUrl}]]></PicUrl>
|
||||
<Url><![CDATA[${url}]]></Url>
|
||||
</item>
|
||||
</Articles>
|
||||
</xml>`;
|
||||
}
|
||||
}
|
||||
367
src/utils/wechatXmlParser.ts
Normal file
367
src/utils/wechatXmlParser.ts
Normal file
@ -0,0 +1,367 @@
|
||||
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, assertWechatReceivedEventType, assertWechatReceivedMessageType } from "./assertUtils";
|
||||
import { WechatReceivedEvent } from "../types/received-event";
|
||||
import { assert } from "console";
|
||||
|
||||
/**
|
||||
* 从原始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 | WechatReceivedEvent> {
|
||||
const xml = parseRawXml(raw);
|
||||
|
||||
// 将可能存在的 __cdata 提取
|
||||
const normalized = normalizeWechatXml(xml);
|
||||
|
||||
const msgType = normalized.MsgType;
|
||||
let eventType: string | undefined;
|
||||
|
||||
// 基础字段类型断言
|
||||
assertString(normalized.ToUserName, 'ToUserName');
|
||||
assertString(normalized.FromUserName, 'FromUserName');
|
||||
assertString(normalized.CreateTime, 'CreateTime');
|
||||
assertString(msgType, 'MsgType');
|
||||
|
||||
if (msgType === 'event') {
|
||||
assertString(normalized.Event, 'Event');
|
||||
eventType = normalized.Event;
|
||||
assertWechatReceivedEventType(eventType, 'Event');
|
||||
|
||||
const baseFields: Omit<WechatReceivedEvent, 'MsgType' | 'Event'> = {
|
||||
ToUserName: normalized.ToUserName,
|
||||
FromUserName: normalized.FromUserName,
|
||||
CreateTime: Number(normalized.CreateTime),
|
||||
};
|
||||
|
||||
// 根据 Event 进行具体事件类型的构建
|
||||
switch (eventType) {
|
||||
case "subscribe":
|
||||
return {
|
||||
...baseFields,
|
||||
MsgType: "event",
|
||||
Event: "subscribe",
|
||||
EventKey: normalized.EventKey,
|
||||
Ticket: normalized.Ticket,
|
||||
} satisfies WechatReceivedEvent;
|
||||
case "SCAN":
|
||||
return {
|
||||
...baseFields,
|
||||
MsgType: "event",
|
||||
Event: "SCAN",
|
||||
EventKey: normalized.EventKey ?? '',
|
||||
Ticket: normalized.Ticket ?? '',
|
||||
} satisfies WechatReceivedEvent;
|
||||
case "LOCATION":
|
||||
assertString(normalized.Latitude, 'Latitude');
|
||||
assertString(normalized.Longitude, 'Longitude');
|
||||
assertString(normalized.Precision, 'Precision');
|
||||
return {
|
||||
...baseFields,
|
||||
MsgType: "event",
|
||||
Event: "LOCATION",
|
||||
Latitude: normalized.Latitude,
|
||||
Longitude: normalized.Longitude,
|
||||
Precision: normalized.Precision,
|
||||
} satisfies WechatReceivedEvent;
|
||||
case "CLICK":
|
||||
assertString(normalized.EventKey, 'EventKey');
|
||||
return {
|
||||
...baseFields,
|
||||
MsgType: "event",
|
||||
Event: "CLICK",
|
||||
EventKey: normalized.EventKey,
|
||||
} satisfies WechatReceivedEvent;
|
||||
case "VIEW":
|
||||
assertString(normalized.EventKey, 'EventKey');
|
||||
return {
|
||||
...baseFields,
|
||||
MsgType: "event",
|
||||
Event: "VIEW",
|
||||
EventKey: normalized.EventKey,
|
||||
} satisfies WechatReceivedEvent;
|
||||
default:
|
||||
const _exhaustiveCheck: never = eventType;
|
||||
throw new Error(`Unsupported Event type: ${_exhaustiveCheck}`);
|
||||
}
|
||||
} else {
|
||||
assertWechatReceivedMessageType(msgType, 'MsgType');
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user