feat: 微信公众号/服务号 被动回复用户消息功能
- 自动回复:根据用户发送的关键词查询Directus自动回复集合,并返回相应的结果。目前支持文本回复与图文回复
This commit is contained in:
110
src/index.ts
110
src/index.ts
@ -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 { 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,88 @@ 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 !== '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_link_replies') {
|
||||
const linkItem = firstReply.item as unknown as { title: string; description: string; url: string; pic_url: string; link_with_questions: boolean; related_question?: string; };
|
||||
console.log("Link Item:", linkItem);
|
||||
// 构造图文消息XML
|
||||
reply = replyBuilder.buildNewsReply(
|
||||
linkItem.title,
|
||||
linkItem.description,
|
||||
linkItem.pic_url,
|
||||
linkItem.url
|
||||
);
|
||||
} else {
|
||||
replyContent = "未找到匹配的回复内容";
|
||||
reply = undefined;
|
||||
}
|
||||
}
|
||||
console.log("Matched Replies:", replies);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
39
src/types/directus-schema.ts
Normal file
39
src/types/directus-schema.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 微信回复消息类型——文本消息
|
||||
*/
|
||||
export interface WechatTextReply {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回复消息类型——图文消息
|
||||
*/
|
||||
export interface WechatLinkReply {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
pic_url: string;
|
||||
link_with_questions: boolean;
|
||||
related_question?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回复消息关联项
|
||||
*/
|
||||
export interface WechatRepliesReply {
|
||||
id: string;
|
||||
wechat_replies_id: string;
|
||||
item: WechatTextReply | WechatLinkReply;
|
||||
collection: 'wechat_text_replies' | 'wechat_link_replies';
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回复消息类型——回复集合
|
||||
*/
|
||||
export interface WechatReply {
|
||||
id: string;
|
||||
keyword: string;
|
||||
reply: WechatRepliesReply[];
|
||||
}
|
||||
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;
|
||||
@ -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]);
|
||||
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user