From 948e7bf1095f7b3aa906722a2f44818dd64e7d22 Mon Sep 17 00:00:00 2001
From: R2m1liA <15258427350@163.com>
Date: Sat, 13 Dec 2025 05:59:27 +0000
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=AE=E4=BF=A1=E5=85=AC=E4=BC=97?=
=?UTF-8?q?=E5=8F=B7/=E6=9C=8D=E5=8A=A1=E5=8F=B7=20=E8=A2=AB=E5=8A=A8?=
=?UTF-8?q?=E5=9B=9E=E5=A4=8D=E7=94=A8=E6=88=B7=E6=B6=88=E6=81=AF=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 自动回复:根据用户发送的关键词查询Directus自动回复集合,并返回相应的结果。目前支持文本回复与图文回复
---
src/index.ts | 110 +++++++++++++++------
src/types/directus-schema.ts | 39 ++++++++
src/types/reply-message.ts | 152 ++++++++++++++++++++++++++++++
src/types/wechat-message.ts | 30 ------
src/utils/wechatCrypto.ts | 2 +-
src/utils/wechatEncryptBuilder.ts | 59 ++++++++++++
src/utils/wechatReplyBuilder.ts | 105 +++++++++++++++++++++
7 files changed, 439 insertions(+), 58 deletions(-)
create mode 100644 src/types/directus-schema.ts
create mode 100644 src/types/reply-message.ts
delete mode 100644 src/types/wechat-message.ts
create mode 100644 src/utils/wechatEncryptBuilder.ts
create mode 100644 src/utils/wechatReplyBuilder.ts
diff --git a/src/index.ts b/src/index.ts
index 7102643..fdbf6bc 100644
--- a/src/index.ts
+++ b/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 = `
-
-
- ${Date.now()}
-
-
- `;
+ 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('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 = `
-
-
- ${timestamp}
-
- `;
+ const responseXml = encryptBuilder.buildResponse(encryptedReply);
res.set('Content-Type', 'application/xml');
return res.send(responseXml);
diff --git a/src/types/directus-schema.ts b/src/types/directus-schema.ts
new file mode 100644
index 0000000..d58c232
--- /dev/null
+++ b/src/types/directus-schema.ts
@@ -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[];
+}
\ No newline at end of file
diff --git a/src/types/reply-message.ts b/src/types/reply-message.ts
new file mode 100644
index 0000000..329267f
--- /dev/null
+++ b/src/types/reply-message.ts
@@ -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;
\ No newline at end of file
diff --git a/src/types/wechat-message.ts b/src/types/wechat-message.ts
deleted file mode 100644
index 4d7fba8..0000000
--- a/src/types/wechat-message.ts
+++ /dev/null
@@ -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;
\ No newline at end of file
diff --git a/src/utils/wechatCrypto.ts b/src/utils/wechatCrypto.ts
index ec7d2b3..1f8dcd9 100644
--- a/src/utils/wechatCrypto.ts
+++ b/src/utils/wechatCrypto.ts
@@ -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]);
diff --git a/src/utils/wechatEncryptBuilder.ts b/src/utils/wechatEncryptBuilder.ts
new file mode 100644
index 0000000..05b3eda
--- /dev/null
+++ b/src/utils/wechatEncryptBuilder.ts
@@ -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 = `
+
+
+${this.timestamp}
+
+`;
+
+ return responseXml;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/wechatReplyBuilder.ts b/src/utils/wechatReplyBuilder.ts
new file mode 100644
index 0000000..1ae66a9
--- /dev/null
+++ b/src/utils/wechatReplyBuilder.ts
@@ -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 & {
+ createTime?: number;
+ }): WechatReplyBuilder {
+ return new WechatReplyBuilder({
+ ...options,
+ createTime: options.createTime ?? Date.now(),
+ });
+ }
+
+ /**
+ * 构造文本消息回复XML
+ *
+ * @param content 文本消息内容
+ * @returns 文本消息回复XML字符串
+ */
+ buildTextReply(content: string): string {
+ return `
+
+
+ ${this.createTime}
+
+
+ `;
+ }
+
+ /**
+ * 构造图文消息回复XML
+ *
+ * @param title 图文消息标题
+ * @param description 图文消息描述
+ * @param picUrl 图文消息图片链接
+ * @param url 图文消息跳转链接
+ * @returns 图文消息回复XML字符串
+ */
+ buildNewsReply(title: string, description: string, picUrl: string, url: string): string {
+ return `
+
+
+ ${this.createTime}
+
+ 1
+
+ -
+
+
+
+
+
+
+ `;
+ }
+}
\ No newline at end of file