本文代码获取方式:Github,走过路过,欢迎点赞和 star,本人会持续优化该项目html
系列文章传送门git
在前面的文章中,咱们完成了微信回调服务器基本流程的的打通,但实际业务场景中,用户给咱们发送的不单单是文本类型的消息,咱们给用户回复的也须要支持不一样的类型。另外,微信对回调消息的响应时间作了规定,不能超过 5 秒,当咱们的业务须要对用户消息处理较长时间再给予答复时,得采用另外的手段来进行消息的回复github
咱们查看 接收普通消息 文档能够得知,当用户发送的消息为图片、语音、视频等媒体类型的消息时,微信给咱们的网关的回调信息中,会有一个 mediaId
redis
微信会将用户消息以 临时素材
的形式存储,并提供 API 让开发者获取该媒体文件。经过 获取临时素材 文档能够发现,除了 mediaId
,咱们还须要一个 access_token
用于请求该接口数据库
关于 access_token
此文档 已经作了很是详细的说明,咱们须要注意如下的信息npm
综合以上信息,咱们能够固化出,微信网关调用微信 API 的流程json
咱们先来处理缓存,此处使用 redis 来实现,若没有 redis,则可使用内存、本地文件或者数据库的方式。集中实现方式的对比,可参考我以前的 文章api
import { Service } from 'egg';
// 缓存数据,若项目中无 redis,则修改方法,使用内存或数据库实现
export default class extends Service {
public async get(key: string) {
const { ctx } = this;
return await ctx.app.redis.get(key);
}
public async set(key: string, value: string, expires = 60 * 60) {
const { ctx } = this;
await ctx.app.redis.set(key, value);
await ctx.app.redis.expire(key, expires);
}
public async expire(key: string, expires = 60 * 60) {
const { ctx } = this;
await ctx.app.redis.expire(key, expires);
}
public async delete(key: string) {
const { ctx } = this;
await ctx.app.redis.del(key);
}
}
复制代码
再把咱们以前的 service/wechat 拆分一层,把 encode 和 decode 方法放入 service/wechat/adapter 中,而后建立 service/wechat/util 用于实现辅助方法,咱们将获取当前有效的 access_token 的方法放入其中缓存
import { Service } from 'egg';
export default class extends Service {
public async getAccessToken() {
const { ctx } = this;
const config = ctx.app.config.wechat;
const cacheKey = `wechat_token_${config.appId}`;
let token = await ctx.service.utils.cache.get(cacheKey);
if (!token) {
const result = await ctx.app.curl(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.appId}&secret=${config.secret}`,
{
dataType: 'json'
}
);
if (result.data.errcode) {
throw new Error('get mp access_token error');
}
token = result.data.access_token;
if (!token) {
throw new Error('get mp access_token error');
}
await ctx.service.utils.cache.set(cacheKey, token, 60 * 60);
}
return { token };
}
}
复制代码
咱们把媒体文件处理的方法单独出来,实现获取媒体文件流的方法安全
import { Service } from 'egg';
export default class extends Service {
public async getMedia(mediaId) {
const { ctx } = this;
const { token } = await ctx.service.wechat.util.getAccessToken();
const result = await ctx.app.curl(
`https://api.weixin.qq.com/cgi-bin/media/get?access_token=${token}&media_id=${mediaId}`,
{
timeout: 30000
}
);
return result;
}
}
复制代码
最后,在 controller 中调用获取媒体文件的方法,便可获得媒体文件的文件流,并根据业务需求进行处理,例如转存到本地,或者自有文件服务器,以便于将来能够被访问到(微信只会存储临时媒体文件 3 天)
// 当用户消息类型为图片、语音、视频类型时,微信回调一个 mediaId
if (message.mediaId) {
// 获取媒体文件流
const buffer = await ctx.service.wechat.media.getMedia(message.mediaId);
// 对媒体文件流作一些处理,例如转存到本地
console.log(buffer.data);
}
复制代码
另外,微信回调消息中,也有用户的一些事件消息,详情可见 接收事件推送,只须要在 decode 函数中把解析到的参数添加到咱们自定义的 message 对象中便可,在此不作赘述
在前面的文章中,咱们已经实现了被动回复文本类型的消息。在 被动回复用户消息 文档中,咱们能够发现,当咱们须要回复图片、语音、视频等类型的消息时,须要把咱们的媒体文件上传到微信服务器并获取一个 mediaId
,再组装响应数据
上传媒体文件的文档参考 新增临时素材
咱们先安装一个 form 请求辅助包 formstream
npm i --save formstream
复制代码
而后在媒体文件的 service 中增长上传文件的方法
/** * 上传媒体文件 * @param type 媒体类型,参考微信官方文档 * @param buffer 媒体文件流 * @param filename 文件名 * @param contentType content-type */
public async uploadMedia(type, buffer, filename, contentType) {
const { ctx } = this;
const { token } = await ctx.service.wechat.util.getAccessToken();
const form = formstream();
form.buffer('media', buffer, filename, contentType);
const result = await ctx.app.curl(
`https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${token}&type=${type}`,
{
method: 'POST',
headers: form.headers(),
stream: form,
dataType: 'json',
timeout: 30000
}
);
return result;
}
复制代码
接着,咱们须要完善一下 encode 方法,来支持组装多种类型的响应消息
// 获取原始回复 xml
private compileReply(info) {
return ejs.compile(
[
'<xml>',
'<ToUserName><![CDATA[<%-toUsername%>]]></ToUserName>',
'<FromUserName><![CDATA[<%-fromUsername%>]]></FromUserName>',
'<CreateTime><%=createTime%></CreateTime>',
'<MsgType><![CDATA[<%=msgType%>]]></MsgType>',
'<% if (msgType === "news") { %>',
'<ArticleCount><%=content.length%></ArticleCount>',
'<Articles>',
'<% content.forEach(function(item){ %>',
'<item>',
'<Title><![CDATA[<%-item.title%>]]></Title>',
'<Description><![CDATA[<%-item.description%>]]></Description>',
'<PicUrl><![CDATA[<%-item.picUrl || item.picurl || item.pic %>]]></PicUrl>',
'<Url><![CDATA[<%-item.url%>]]></Url>',
'</item>',
'<% }); %>',
'</Articles>',
'<% } else if (msgType === "music") { %>',
'<Music>',
'<Title><![CDATA[<%-content.title%>]]></Title>',
'<Description><![CDATA[<%-content.description%>]]></Description>',
'<MusicUrl><![CDATA[<%-content.musicUrl || content.url %>]]></MusicUrl>',
'<HQMusicUrl><![CDATA[<%-content.hqMusicUrl || content.hqUrl %>]]></HQMusicUrl>',
'</Music>',
'<% } else if (msgType === "voice") { %>',
'<Voice>',
'<MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>',
'</Voice>',
'<% } else if (msgType === "image") { %>',
'<Image>',
'<MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>',
'</Image>',
'<% } else if (msgType === "video") { %>',
'<Video>',
'<MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>',
'<Title><![CDATA[<%-content.title%>]]></Title>',
'<Description><![CDATA[<%-content.description%>]]></Description>',
'</Video>',
'<% } else { %>',
'<Content><![CDATA[<%-content%>]]></Content>',
'<% } %>',
'</xml>'
].join('')
)(info);
}
复制代码
最后,在 controller 中根据须要响应用户消息
例如,咱们实现用户媒体类型消息的回复,用户发图片、语音就原样回复过去
// 正常状况下,服务端须要先将媒体文件经过 ctx.service.wechat.media.uploadMedia 方法上传至微信服务器获取 mediaId
// eg: 将用户原始消息直接回复
if (message.msgType === 'text') {
ctx.body = await ctx.service.wechat.adapter.encodeMsg({
type: 'text',
content: message.content
});
} else {
ctx.body = await ctx.service.wechat.adapter.encodeMsg({
type: message.msgType,
content: {
mediaId: message.mediaId
}
});
}
复制代码
若是是回复图文消息,则变成
// eg: 图文消息回复
ctx.body = await ctx.service.wechat.adapter.encodeMsg({
type: 'news',
content: [
{
title: '掘金社区',
description: '一块儿来学习吧',
url: 'https://juejin.cn'
}
]
});
复制代码
在有些场景下,咱们并非能够及时响应用户的消息,而是要通过必定时间的处理(超过 5 秒),才能给用户响应,这个时候就须要主动给用户发送消息
参考 官方文档,咱们能够在收到用户消息的 48 小时内,调用客服消息接口给用户主动发送消息,类型包括:文本、图片、语音、视频、图文等
咱们新建一个 message 文件用于处理消息发送的逻辑,实现常见的客服消息发送方法
import { Service } from 'egg';
import { IMessage } from '../../interface/message';
export interface IMenuMsg {
headContent?: string;
tailContent?: string;
list: { id: number; content: string }[];
}
export default class extends Service {
// 调用微信客服消息 API
public async sendMsg(data: any) {
const { ctx } = this;
const { token } = await this.service.wechat.util.getAccessToken();
const result = await ctx.app.curl(
`https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${token}`,
{
method: 'POST',
contentType: 'json',
dataType: 'json',
data
}
);
return result;
}
// 文本消息
public async sendTextMsg(message: IMessage) {
await this.sendMsg({
touser: message.userId,
msgtype: 'text',
text: {
content: message.content
}
});
}
// 图片消息
public async sendImageMsg(message: IMessage) {
await this.sendMsg({
touser: message.userId,
msgtype: 'image',
image: {
media_id: message.mediaId
}
});
}
// 语音消息
public async sendVoiceMsg(message: IMessage) {
await this.sendMsg({
touser: message.userId,
msgtype: 'voice',
voice: {
media_id: message.mediaId
}
});
}
// 视频消息
public async sendVideoMsg(message: IMessage) {
await this.sendMsg({
touser: message.userId,
msgtype: 'video',
video: {
media_id: message.mediaId
}
});
}
// 图文消息
public async sendNewsMsg(message: IMessage) {
await this.sendMsg({
touser: message.userId,
msgtype: 'news',
news: {
articles: message.articles
}
});
}
// 菜单消息
public async sendMenuMsg(message: IMessage, data: IMenuMsg) {
await this.sendMsg({
touser: message.userId,
msgtype: 'msgmenu',
msgmenu: {
head_content: data.headContent + '\n',
list: data.list,
tail_content: '\n' + data.tailContent
}
});
}
}
复制代码
在 controller 中,先给予微信一个空值响应,间隔一段时间后,再发送消息给用户
// 先给微信服务器一个响应
ctx.body = await ctx.service.wechat.adapter.encodeMsg('');
// 模拟一段时间的处理后,给用户主动推送消息
setTimeout(() => {
ctx.service.wechat.message.sendTextMsg({
...message,
content: '哈喽,这是五秒后的回复'
});
}, 5000);
复制代码
同时,为了给用户一个更好的体验,咱们能够利用 客服输入状态
这个 API,让用户在服务器处理的期间,能看到 对方正在输入...
这样的提示,而不是空荡荡的,觉得公众号出问题了
// 客服输入状态
public async typing(message: IMessage, isTyping = true) {
const { ctx } = this;
const { token } = await this.service.wechat.util.getAccessToken();
const result = await ctx.app.curl(
`https://api.weixin.qq.com/cgi-bin/message/custom/typing?access_token=${token}`,
{
method: 'POST',
contentType: 'json',
dataType: 'json',
data: {
touser: message.userId,
command: isTyping ? 'Typing' : 'CancelTyping'
}
}
);
return result;
}
复制代码
// 先给微信服务器一个响应
ctx.body = await ctx.service.wechat.adapter.encodeMsg('');
ctx.service.wechat.message.typing(message, true);
// 模拟一段时间的处理后,给用户主动推送消息
setTimeout(() => {
ctx.service.wechat.message.typing(message, false);
ctx.service.wechat.message.sendTextMsg({
...message,
content: '哈喽,这是五秒后的回复'
});
}, 5000);
复制代码
更多的消息类型大同小异,你们可自行实现
至此,咱们的服务已经实现了解析多种类型的用户消息,按需被动回复不一样类型的消息,而且在必要的时候能够主动给用户发送消息。咱们实现了 access_token 的通用处理方式,在将来调用微信提供的 API 时能够直接复用
在下一篇内容里,咱们将探索基于公众号的网页开发,敬请期待
本文代码获取方式:Github