Compare commits

...

6 Commits

Author SHA1 Message Date
545d1e687f feat: 添加事件推送处理
- 当前不处理任何事件推送
2025-12-26 04:55:51 +00:00
ac4cea4658 chore: 添加微信事件推送类型 2025-12-26 04:41:16 +00:00
fd74fc0f2d fix: 修正图文消息命名
- link_with_questions -> enable_question
2025-12-13 07:31:49 +00:00
0d0699f40c fix: 修正图文消息命名
- link_reply -> news_reply
2025-12-13 07:27:22 +00:00
9f5157c5e1 feat: 添加图文回复关联问题
- 启用关联问题后,图文回复直接链接至对应问题页面
2025-12-13 07:17:57 +00:00
948e7bf109 feat: 微信公众号/服务号 被动回复用户消息功能
- 自动回复:根据用户发送的关键词查询Directus自动回复集合,并返回相应的结果。目前支持文本回复与图文回复
2025-12-13 06:00:21 +00:00
10 changed files with 795 additions and 156 deletions

View File

@ -1,12 +1,16 @@
import { defineEndpoint } from "@directus/extensions-sdk";
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;
@ -37,36 +41,130 @@ export default defineEndpoint({
const cryptoUtil = new WechatCrypto(encodingAESKey, appId);
const decryptXml = cryptoUtil.decrypt(encrypt);
const msg = await parseDecryptedXML(decryptXml);
console.log("Parsed Message:", msg);
let replyContent;
// if (msg.MsgType !== 'text') {
// replyContent = "暂不支持该消息类型";
// } else {
// replyContent = `已收到你的加密消息:${msg.Content}, 消息类型: ${msg.MsgType}`;
// }
replyContent = `已收到你的加密消息,消息类型: ${msg.MsgType}`;
// 回复消息(示例:文本)
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>`;
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;
}
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);

View 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[];
}

114
src/types/received-event.ts Normal file
View 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;

152
src/types/reply-message.ts Normal file
View 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;

View File

@ -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;
/** 消息id64位整型 */
MsgId: string;
/** 消息数据id,消息来自文章时存在 */
MsgDataId?: string;
/** 多图文时的文章索引从1开始消息来自文章时存在 */
Idx?: string;
}
export type WechatMessage = WechatTextMessage;

View File

@ -31,3 +31,24 @@ export function assertWechatReceivedMessageType(value: unknown, fieldName: strin
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}`);
}
}

View File

@ -113,7 +113,7 @@ export class WechatCrypto {
// 消息长度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]);

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

View 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>`;
}
}

View File

@ -4,7 +4,9 @@ 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";
import { assertString, assertWechatReceivedEventType, assertWechatReceivedMessageType } from "./assertUtils";
import { WechatReceivedEvent } from "../types/received-event";
import { assert } from "console";
/**
* 从原始XML字符串解析为微信XML对象
@ -197,30 +199,84 @@ export async function parseWechatEncryptMessage(req: Request): Promise<WechatEnc
* // }
* ```
*/
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;
export async function parseDecryptedXML(raw: string): Promise<WechatReceivedMessage | WechatReceivedEvent> {
const xml = parseRawXml(raw);
// 将可能存在的 __cdata 提取
const normalized = normalizeWechatXml(xml);
const msgType = normalized.MsgType;
assertString(msgType, 'MsgType');
assertWechatReceivedMessageType(msgType, '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'> = {
@ -307,4 +363,5 @@ export async function parseDecryptedXML(raw: string): Promise<WechatReceivedMess
const _exhaustiveCheck: never = msgType;
throw new Error(`Unsupported MsgType: ${_exhaustiveCheck}`);
}
}
}