Compare commits
2 Commits
fd74fc0f2d
...
545d1e687f
| Author | SHA1 | Date | |
|---|---|---|---|
| 545d1e687f | |||
| ac4cea4658 |
@ -51,6 +51,14 @@ export default defineEndpoint({
|
|||||||
let replyContent;
|
let replyContent;
|
||||||
let reply;
|
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') {
|
if (MsgType !== 'text') {
|
||||||
replyContent = "暂不支持该消息类型";
|
replyContent = "暂不支持该消息类型";
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
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;
|
||||||
@ -30,4 +30,25 @@ export function assertWechatReceivedMessageType(value: unknown, fieldName: strin
|
|||||||
if (typeof value !== 'string' || !WECHAT_RECEIVED_MESSAGE_TYPES.includes(value as 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}`);
|
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,10 +1,12 @@
|
|||||||
import { XMLParser } from "fast-xml-parser";
|
import { XMLParser } from "fast-xml-parser";
|
||||||
import getRawBody from "raw-body";
|
import getRawBody from "raw-body";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import type { WechatReceivedBaseMessage, WechatReceivedImageMessage, WechatReceivedLinkMessage, WechatReceivedLocationMessage, WechatReceivedMessage, WechatReceivedTextMessage, WechatReceivedVideoMessage, WechatReceivedVoiceMessage } from "../types/received-message";
|
import type { WechatReceivedBaseMessage, WechatReceivedImageMessage, WechatReceivedLinkMessage, WechatReceivedLocationMessage, WechatReceivedMessage, WechatReceivedTextMessage, WechatReceivedVideoMessage, WechatReceivedVoiceMessage } from "../types/received-message";
|
||||||
import type { WechatEncryptMessage } from "../types/encrypted-message";
|
import type { WechatEncryptMessage } from "../types/encrypted-message";
|
||||||
import { WechatXmlObject, NormalizedWechatXml } from "../types/wechat-xml";
|
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对象
|
* 从原始XML字符串解析为微信XML对象
|
||||||
@ -197,114 +199,169 @@ export async function parseWechatEncryptMessage(req: Request): Promise<WechatEnc
|
|||||||
* // }
|
* // }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export async function parseDecryptedXML(raw: string): Promise<WechatReceivedMessage> {
|
export async function parseDecryptedXML(raw: string): Promise<WechatReceivedMessage | WechatReceivedEvent> {
|
||||||
// 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);
|
const xml = parseRawXml(raw);
|
||||||
|
|
||||||
// 将可能存在的 __cdata 提取
|
// 将可能存在的 __cdata 提取
|
||||||
const normalized = normalizeWechatXml(xml);
|
const normalized = normalizeWechatXml(xml);
|
||||||
|
|
||||||
const msgType = normalized.MsgType;
|
const msgType = normalized.MsgType;
|
||||||
assertString(msgType, 'MsgType');
|
let eventType: string | undefined;
|
||||||
assertWechatReceivedMessageType(msgType, 'MsgType');
|
|
||||||
|
|
||||||
// 基础字段类型断言
|
// 基础字段类型断言
|
||||||
assertString(normalized.ToUserName, 'ToUserName');
|
assertString(normalized.ToUserName, 'ToUserName');
|
||||||
assertString(normalized.FromUserName, 'FromUserName');
|
assertString(normalized.FromUserName, 'FromUserName');
|
||||||
assertString(normalized.CreateTime, 'CreateTime');
|
assertString(normalized.CreateTime, 'CreateTime');
|
||||||
assertString(normalized.MsgId, 'MsgId');
|
assertString(msgType, 'MsgType');
|
||||||
|
|
||||||
const baseFields: Omit<WechatReceivedBaseMessage, 'MsgType'> = {
|
if (msgType === 'event') {
|
||||||
ToUserName: normalized.ToUserName,
|
assertString(normalized.Event, 'Event');
|
||||||
FromUserName: normalized.FromUserName,
|
eventType = normalized.Event;
|
||||||
CreateTime: Number(normalized.CreateTime),
|
assertWechatReceivedEventType(eventType, 'Event');
|
||||||
MsgId: normalized.MsgId,
|
|
||||||
MsgDataId: normalized.MsgDataId,
|
|
||||||
Idx: normalized.Idx,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据 MsgType 进行具体消息类型的构建
|
const baseFields: Omit<WechatReceivedEvent, 'MsgType' | 'Event'> = {
|
||||||
switch (msgType) {
|
ToUserName: normalized.ToUserName,
|
||||||
case "text":
|
FromUserName: normalized.FromUserName,
|
||||||
assertString(normalized.Content, 'Content');
|
CreateTime: Number(normalized.CreateTime),
|
||||||
return {
|
};
|
||||||
...baseFields,
|
|
||||||
MsgType: "text",
|
// 根据 Event 进行具体事件类型的构建
|
||||||
Content: normalized.Content,
|
switch (eventType) {
|
||||||
} satisfies WechatReceivedTextMessage;
|
case "subscribe":
|
||||||
case "image":
|
return {
|
||||||
assertString(normalized.PicUrl, 'PicUrl');
|
...baseFields,
|
||||||
assertString(normalized.MediaId, 'MediaId');
|
MsgType: "event",
|
||||||
return {
|
Event: "subscribe",
|
||||||
...baseFields,
|
EventKey: normalized.EventKey,
|
||||||
MsgType: "image",
|
Ticket: normalized.Ticket,
|
||||||
PicUrl: normalized.PicUrl,
|
} satisfies WechatReceivedEvent;
|
||||||
MediaId: normalized.MediaId,
|
case "SCAN":
|
||||||
} satisfies WechatReceivedImageMessage;
|
return {
|
||||||
case "voice":
|
...baseFields,
|
||||||
assertString(normalized.MediaId, 'MediaId');
|
MsgType: "event",
|
||||||
assertString(normalized.Format, 'Format');
|
Event: "SCAN",
|
||||||
assertString(normalized.MediaId16K, 'MediaId16K');
|
EventKey: normalized.EventKey ?? '',
|
||||||
return {
|
Ticket: normalized.Ticket ?? '',
|
||||||
...baseFields,
|
} satisfies WechatReceivedEvent;
|
||||||
MsgType: "voice",
|
case "LOCATION":
|
||||||
MediaId: normalized.MediaId,
|
assertString(normalized.Latitude, 'Latitude');
|
||||||
Format: normalized.Format,
|
assertString(normalized.Longitude, 'Longitude');
|
||||||
MediaId16K: normalized.MediaId16K,
|
assertString(normalized.Precision, 'Precision');
|
||||||
} satisfies WechatReceivedVoiceMessage;
|
return {
|
||||||
case "video":
|
...baseFields,
|
||||||
assertString(normalized.MediaId, 'MediaId');
|
MsgType: "event",
|
||||||
assertString(normalized.ThumbMediaId, 'ThumbMediaId');
|
Event: "LOCATION",
|
||||||
return {
|
Latitude: normalized.Latitude,
|
||||||
...baseFields,
|
Longitude: normalized.Longitude,
|
||||||
MsgType: "video",
|
Precision: normalized.Precision,
|
||||||
MediaId: normalized.MediaId,
|
} satisfies WechatReceivedEvent;
|
||||||
ThumbMediaId: normalized.ThumbMediaId,
|
case "CLICK":
|
||||||
} satisfies WechatReceivedVideoMessage;
|
assertString(normalized.EventKey, 'EventKey');
|
||||||
case "shortvideo":
|
return {
|
||||||
assertString(normalized.MediaId, 'MediaId');
|
...baseFields,
|
||||||
assertString(normalized.ThumbMediaId, 'ThumbMediaId');
|
MsgType: "event",
|
||||||
return {
|
Event: "CLICK",
|
||||||
...baseFields,
|
EventKey: normalized.EventKey,
|
||||||
MsgType: "shortvideo",
|
} satisfies WechatReceivedEvent;
|
||||||
MediaId: normalized.MediaId,
|
case "VIEW":
|
||||||
ThumbMediaId: normalized.ThumbMediaId,
|
assertString(normalized.EventKey, 'EventKey');
|
||||||
} satisfies WechatReceivedVideoMessage;
|
return {
|
||||||
case "location":
|
...baseFields,
|
||||||
assertString(normalized.Location_X, 'Location_X');
|
MsgType: "event",
|
||||||
assertString(normalized.Location_Y, 'Location_Y');
|
Event: "VIEW",
|
||||||
assertString(normalized.Scale, 'Scale');
|
EventKey: normalized.EventKey,
|
||||||
assertString(normalized.Label, 'Label');
|
} satisfies WechatReceivedEvent;
|
||||||
return {
|
default:
|
||||||
...baseFields,
|
const _exhaustiveCheck: never = eventType;
|
||||||
MsgType: "location",
|
throw new Error(`Unsupported Event type: ${_exhaustiveCheck}`);
|
||||||
Location_X: normalized.Location_X,
|
}
|
||||||
Location_Y: normalized.Location_Y,
|
} else {
|
||||||
Scale: normalized.Scale,
|
assertWechatReceivedMessageType(msgType, 'MsgType');
|
||||||
Label: normalized.Label,
|
assertString(normalized.MsgId, 'MsgId');
|
||||||
} satisfies WechatReceivedLocationMessage;
|
|
||||||
case "link":
|
const baseFields: Omit<WechatReceivedBaseMessage, 'MsgType'> = {
|
||||||
assertString(normalized.Title, 'Title');
|
ToUserName: normalized.ToUserName,
|
||||||
assertString(normalized.Description, 'Description');
|
FromUserName: normalized.FromUserName,
|
||||||
assertString(normalized.Url, 'Url');
|
CreateTime: Number(normalized.CreateTime),
|
||||||
return {
|
MsgId: normalized.MsgId,
|
||||||
...baseFields,
|
MsgDataId: normalized.MsgDataId,
|
||||||
MsgType: "link",
|
Idx: normalized.Idx,
|
||||||
Title: normalized.Title,
|
};
|
||||||
Description: normalized.Description,
|
|
||||||
Url: normalized.Url,
|
// 根据 MsgType 进行具体消息类型的构建
|
||||||
} satisfies WechatReceivedLinkMessage;
|
switch (msgType) {
|
||||||
default:
|
case "text":
|
||||||
const _exhaustiveCheck: never = msgType;
|
assertString(normalized.Content, 'Content');
|
||||||
throw new Error(`Unsupported MsgType: ${_exhaustiveCheck}`);
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user