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 + + + <![CDATA[${title}]]> + + + + + + `; + } +} \ No newline at end of file