一直在做小程序,可以對(duì)于后端還是一知半解。近些天在看node相關(guān)的內(nèi)容,于是想嘗試用node寫寫接口,全當(dāng)自己學(xué)習(xí)也可以順便補(bǔ)充補(bǔ)充后端的一些知識(shí)。查閱微信小程序的文檔突然看到了客服消息這塊的內(nèi)容,看到只需調(diào)用微信提供的一些接口就可以接入,頓時(shí)就有那么一絲絲興趣了,同時(shí)也順便練習(xí)下node相關(guān)的智知識(shí)。說(shuō)干就干,于是乎就注冊(cè)了個(gè)微信小程序的號(hào),好在微信在客服消息這方面?zhèn)€人小程序號(hào)沒(méi)有限制,暗暗自喜,哈哈。
關(guān)于node之前有了解點(diǎn)兒 koa 相關(guān)可是一直沒(méi)有幾乎練手,趁此機(jī)會(huì)就用它了。所以整個(gè)用到的實(shí)現(xiàn)也就用到了下面一些。關(guān)于客戶端的一些設(shè)置調(diào)用內(nèi)容不多,這里主要就記錄下用koa做后端的一些過(guò)程吧。
-
客戶端(微信小程序):
微信小程序button組件<button open-type="contact">進(jìn)入客服會(huì)話</button>關(guān)于客服按鈕的一些設(shè)置下面是官網(wǎng)文檔中提到的一些屬性:
image.png 服務(wù)端:koa框架
依賴微信的應(yīng)用功能還是得先看看官方客服功能使用指南, 其中提到兩種方式:
- 普通方式, 在小程序的公眾平臺(tái)進(jìn)行設(shè)置客服人員;
- 通過(guò)小程序客服消息API的方式來(lái)接入客服。
這里既然要通過(guò)后端來(lái)自動(dòng)處理客服消息,肯定是使用第二種方式咯。當(dāng)然了既然要消息轉(zhuǎn)發(fā)就得告訴微信服務(wù)器消息需要轉(zhuǎn)發(fā)到哪里去,因此需要在微信小程序公眾平臺(tái)(設(shè)置 => 開(kāi)發(fā)設(shè)置 => 消息推送)中配置一個(gè)客服消息轉(zhuǎn)發(fā)的地址。在配置的時(shí)候我才發(fā)現(xiàn),這個(gè)地址是需要微信服務(wù)器去驗(yàn)證的。

這里在做的時(shí)候我選用的是安全模式和JSON格式。這里配置的地址是需要驗(yàn)證的,大家可能已經(jīng)注意到最上面的一句提示
填寫的URL需要正確響應(yīng)微信發(fā)送的Token驗(yàn)證,填寫說(shuō)明請(qǐng)閱讀消息推送服務(wù)器配置指南
已經(jīng)提示的很明顯了,那就按照消息推送服務(wù)器配置指南繼續(xù)往下走。
1. 配置客服消息轉(zhuǎn)發(fā)地址
這一步主要是搭建以為Web服務(wù)器,驗(yàn)證消息來(lái)自微信服務(wù)器的消息并做成正確的響應(yīng)就ok了。下面是一些主要代碼, 這也就完成了我們的第一步:
const crypto = require('crypto') // 加密模塊
const Koa = require('koa');
const app = new Koa();
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');
app.use(bodyParser());
// 消息服務(wù)器驗(yàn)證
Router.get('/', ctx => {
// 1.獲取微信服務(wù)器Get請(qǐng)求的參數(shù) signature、timestamp、nonce、echostr
const {
signature,
timestamp,
nonce,
echostr
} = ctx.request.query;
// 2.將token、timestamp、nonce三個(gè)參數(shù)進(jìn)行字典序排序
let array = [miniAppConfig.token, timestamp, nonce]
array.sort()
// 3.將三個(gè)參數(shù)字符串拼接成一個(gè)字符串進(jìn)行sha1加密
const tempStr = array.join('')
const hashCode = crypto.createHash('sha1') //創(chuàng)建加密類型
const resultCode = hashCode.update(tempStr, 'utf8').digest('hex')
// 4.開(kāi)發(fā)者獲得加密后的字符串可與signature對(duì)比,標(biāo)識(shí)該請(qǐng)求來(lái)源于微信
if (resultCode === signature) {
ctx.body = echostr;
} else {
// 非微信服務(wù)器請(qǐng)求
ctx.body = {
code: -1,
data: '驗(yàn)證失敗'
};
}
});
// 路由
app.use(Router.routes()).use(Router.allowedMethods());
關(guān)于域名https的配置這里就贅述了,可以自行g(shù)oogle之。至此消息接入地址已經(jīng)配置完成。接下來(lái)就繼續(xù)按照文檔來(lái)完成消息的接收和發(fā)送。
當(dāng)用戶在客服會(huì)話發(fā)送消息(或進(jìn)行某些特定的用戶操作引發(fā)的事件推送時(shí)),微信服務(wù)器會(huì)將消息(或事件)的數(shù)據(jù)包(JSON或者XML格式)POST請(qǐng)求開(kāi)發(fā)者填寫的URL。開(kāi)發(fā)者收到請(qǐng)求后可以使用發(fā)送客服消息接口進(jìn)行異步回復(fù)。
下面是主文件 app.js
const decryptWXContact = require('./decryptContact'); // 微信消息解密
const WX = require('./wx');
const miniapp = new WX({
token: 'your token',
appID: 'your appID',
appScrect: 'your appScrect'
});
// 接收并處理用戶消息
router.post('/', async ctx => {
// 加密方式
const { ToUserName, Encrypt } = ctx.request.body;
const decryptData = decryptWXContact(Encrypt);
const { MsgType, FromUserName, MediaId } = decryptData;
if (MsgType === 'text') { // 文本消息
miniapp.sendTextMessage(FromUserName, replyMsg);
}
// 非加密方式
// const { MsgType, FromUserName, Content, Event } = ctx.request.body;
ctx.body = 'success';
})
其中引用的 decryptContact.js 是一個(gè)消息解密模塊,還記得之前配置的消息的時(shí)候嗎,這里的解密就需要用到了;當(dāng)然如果配置的是明文消息這個(gè)也就不需要了, 下面是文件內(nèi)容。
// decryptContact.js
const crypto = require('crypto') // 加密模塊
const miniAppConfig = require('./wx_config');
const decodePKCS7 = function (buff) {
let pad = buff[buff.length - 1];
if (pad < 1 || pad > 32) {
pad = 0;
}
return buff.slice(0, buff.length - pad);
};
// 微信轉(zhuǎn)發(fā)客服消息解密
const decryptContact = (key, iv, crypted) => {
const aesCipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
aesCipher.setAutoPadding(false);
let decipheredBuff = Buffer.concat([aesCipher.update(crypted, 'base64'), aesCipher.final()]);
decipheredBuff = decodePKCS7(decipheredBuff);
const lenNetOrderCorpid = decipheredBuff.slice(16);
const msgLen = lenNetOrderCorpid.slice(0, 4).readUInt32BE(0);
const result = lenNetOrderCorpid.slice(4, msgLen + 4).toString();
return result;
};
// 解密微信返回給配置的消息服務(wù)器的信息
const decryptWXContact = (wechatData) => {
const key = new Buffer(miniAppConfig.EncodingAESKey + '=', 'base64');
const iv = key.slice(0, 16);
const result = decryptContact(key, iv, wechatData);
const decryptedResult = JSON.parse(result);
console.log(decryptedResult);
return decryptedResult;
};
module.exports = decryptWXContact;
在來(lái)看看 wx.js
const fs = require('fs');
const path = require('path');
const request = require('request-promise');
const domain = `https://api.weixin.qq.com`;
const apis = {
token: `${domain}/cgi-bin/token`, // 獲取token
sendMessage: `${domain}/cgi-bin/message/custom/send`, // 發(fā)送消息
};
const accessTokenJson = require('./access_token.json'); //引入本地存儲(chǔ)的 access_token
class Wechat {
constructor(config) {
this.config = config;
this.token = config.token
this.appID = config.appID
this.appScrect = config.appScrect
}
// 獲取AccessToken
getAccessToken() {
return new Promise((resolve, reject) => {
const currentTime = new Date().getTime();
const url = `${apis.token}?grant_type=client_credential&appid=${this.appID}&secret=${this.appScrect}`;
// 過(guò)期判斷
if (!accessTokenJson.access_token || accessTokenJson.access_token == '' || accessTokenJson.expires_time < currentTime) {
request(url).then(data => {
const res = JSON.parse(data);
if (data.indexOf('errcode' < 0)) {
accessTokenJson.access_token = res.access_token;
accessTokenJson.expires_time = new Date().getTime() + (parseInt(res.expires_in) - 200) * 1000;
// 存儲(chǔ)新的 access_token
fs.writeFile('./src/api/message/access_token.json', JSON.stringify(accessTokenJson), (err, res) => {
if (err) {
reject();
return;
}
})
resolve(accessTokenJson.access_token);
} else {
resolve(res);
}
}).catch((err) => {
reject();
})
} else {
resolve(accessTokenJson.access_token);
}
})
}
// 發(fā)送文本消息
async sendTextMessage(openid, message) {
const token = await this.getAccessToken();
const msgData = {
"touser": openid,
"msgtype": 'text',
"text": {
"content": message
}
}
return request({
method: 'POST',
uri: `${apis.sendMessage}?access_token=${token}`,
body: msgData,
json: true
})
}
}
module.exports = Wechat
這個(gè)文件就定義了一個(gè) Wechat 類,其中 getAccessToken 方法需要注意,由于和微信服務(wù)區(qū)交互的過(guò)程中很多的操作都是需要帶上 AccessToken 的,并且這個(gè)值也會(huì)有一個(gè)過(guò)期時(shí)間,所以每次如果過(guò)期的話就不許重新獲取新的 AccessToken 值。我這里沒(méi)有額外的存儲(chǔ),只是將 AccessToken 值以一個(gè)JSON文件的方式存儲(chǔ)在服務(wù)器,每次去讀取。大家也可以將其存在緩存數(shù)據(jù)庫(kù)也是可以的。
這里還需要注意下面幾點(diǎn)
- 在發(fā)送消息的時(shí)數(shù)據(jù)是JSON格式,JOSN中key的雙引號(hào)是必須要的,要不然可能就會(huì)報(bào)
{"errcode":40003,"errmsg":"invalid openid hint: [4zOata0077ge25]"}這個(gè)錯(cuò)誤。 稍不留神就可能掉坑兒, 哈哈。 - 注意
app.js中對(duì)post消息的處理,處理完成后需要需要返回一個(gè) success 給微信服務(wù)器,否則可能出現(xiàn): 除了自動(dòng)回復(fù)的消息外還會(huì)有個(gè)"該小程序提供的服務(wù)出現(xiàn)故障,請(qǐng)稍后再試"的字樣。

服務(wù)器收到請(qǐng)求必須做出下述回復(fù),這樣微信服務(wù)器才不會(huì)對(duì)此作任何處理,并且不會(huì)發(fā)起重試,否則,將出現(xiàn)嚴(yán)重的錯(cuò)誤提示。所以需要添加
ctx.body = 'success';返回值。 其實(shí)這里官網(wǎng)也有說(shuō)明,詳見(jiàn)如下:
1、直接回復(fù)success(推薦方式)
2、直接回復(fù)空串(指字節(jié)長(zhǎng)度為0的空字符串,而不是結(jié)構(gòu)體中content字段的內(nèi)容為空)
一旦遇到以下情況,微信都會(huì)在小程序會(huì)話中,向用戶下發(fā)系統(tǒng)提示“該小程序客服暫時(shí)無(wú)法提供服務(wù),請(qǐng)稍后再試”:
1、開(kāi)發(fā)者在5秒內(nèi)未回復(fù)任何內(nèi)容
2、開(kāi)發(fā)者回復(fù)了異常數(shù)據(jù)。
到此我們就處理了小程序客服中的文本消息,其他還有一些類型的消息也是類似的,只是消息格式類型有所不同。在調(diào)試的時(shí)候我發(fā)現(xiàn)微信新增了小程序文本鏈接消息
const replyMsg = `<a data-miniprogram-appid="wxde9a5002adee16bd" data-miniprogram-path="/pages/login/code">點(diǎn)擊跳小程序</a>`;
miniapp.sendTextMessage(FromUserName, replyMsg);
展示的效果么就像下面這樣:

剩下的時(shí)間就可以補(bǔ)充其他(圖文消息、圖片消息、小程序消息)的處理了,同時(shí)關(guān)于客服消息還有一個(gè)轉(zhuǎn)發(fā)的功能。我們這里做的只不過(guò)是一個(gè)固定的消息自動(dòng)回復(fù),畢竟還是不夠智能。所有有些客服問(wèn)題還是得轉(zhuǎn)到人工處理,具體可以參考消息轉(zhuǎn)發(fā)。
參考閱讀
