koa接入微信小程序客服消息

一直在做小程序,可以对于后端还是一知半解。近些天在看node相关的内容,于是想尝试用node写写接口,全当自己学习也可以顺便补充补充后端的一些知识。查阅微信小程序的文档突然看到了客服消息这块的内容,看到只需调用微信提供的一些接口就可以接入,顿时就有那么一丝丝兴趣了,同时也顺便练习下node相关的智知识。说干就干,于是乎就注册了个微信小程序的号,好在微信在客服消息这方面个人小程序号没有限制,暗暗自喜,哈哈。

关于node之前有了解点儿 koa 相关可是一直没有几乎练手,趁此机会就用它了。所以整个用到的实现也就用到了下面一些。关于客户端的一些设置调用内容不多,这里主要就记录下用koa做后端的一些过程吧。

  • 客户端(微信小程序):
    微信小程序button组件 <button open-type="contact">进入客服会话</button> 关于客服按钮的一些设置下面是官网文档中提到的一些属性:

    image.png

  • 服务端:koa框架

依赖微信的应用功能还是得先看看官方客服功能使用指南, 其中提到两种方式:

  1. 普通方式, 在小程序的公众平台进行设置客服人员;
  2. 通过小程序客服消息API的方式来接入客服。
    这里既然要通过后端来自动处理客服消息,肯定是使用第二种方式咯。当然了既然要消息转发就得告诉微信服务器消息需要转发到哪里去,因此需要在微信小程序公众平台(设置 => 开发设置 => 消息推送)中配置一个客服消息转发的地址。在配置的时候我才发现,这个地址是需要微信服务器去验证的。
image.png

这里在做的时候我选用的是安全模式和JSON格式。这里配置的地址是需要验证的,大家可能已经注意到最上面的一句提示

填写的URL需要正确响应微信发送的Token验证,填写说明请阅读消息推送服务器配置指南

已经提示的很明显了,那就按照消息推送服务器配置指南继续往下走。

1. 配置客服消息转发地址

这一步主要是搭建以为Web服务器,验证消息来自微信服务器的消息并做成正确的响应就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());

// 消息服务器验证
Router.get('/', ctx => {
  // 1.获取微信服务器Get请求的参数 signature、timestamp、nonce、echostr
  const {
    signature,
    timestamp,
    nonce,
    echostr
  } = ctx.request.query;

  // 2.将token、timestamp、nonce三个参数进行字典序排序
  let array = [miniAppConfig.token, timestamp, nonce]
  array.sort()

  // 3.将三个参数字符串拼接成一个字符串进行sha1加密
  const tempStr = array.join('')
  const hashCode = crypto.createHash('sha1') //创建加密类型
  const resultCode = hashCode.update(tempStr, 'utf8').digest('hex')

  // 4.开发者获得加密后的字符串可与signature对比,标识该请求来源于微信
  if (resultCode === signature) {
    ctx.body = echostr;
  } else {
    // 非微信服务器请求
    ctx.body = {
      code: -1,
      data: '验证失败'
    };
  }
});

// 路由
app.use(Router.routes()).use(Router.allowedMethods());

关于域名https的配置这里就赘述了,可以自行google之。至此消息接入地址已经配置完成。接下来就继续按照文档来完成消息的接收和发送。

当用户在客服会话发送消息(或进行某些特定的用户操作引发的事件推送时),微信服务器会将消息(或事件)的数据包(JSON或者XML格式)POST请求开发者填写的URL。开发者收到请求后可以使用发送客服消息接口进行异步回复。

下面是主文件 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 是一个消息解密模块,还记得之前配置的消息的时候吗,这里的解密就需要用到了;当然如果配置的是明文消息这个也就不需要了, 下面是文件内容。

// 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);
};

// 微信转发客服消息解密
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;
};

// 解密微信返回给配置的消息服务器的信息
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;

在来看看 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`, // 发送消息
};
const accessTokenJson = require('./access_token.json'); //引入本地存储的 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}`;
      // 过期判断
      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;
            // 存储新的 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);
      }
    })
  }


  // 发送文本消息
  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

这个文件就定义了一个 Wechat 类,其中 getAccessToken 方法需要注意,由于和微信服务区交互的过程中很多的操作都是需要带上 AccessToken 的,并且这个值也会有一个过期时间,所以每次如果过期的话就不许重新获取新的 AccessToken 值。我这里没有额外的存储,只是将 AccessToken 值以一个JSON文件的方式存储在服务器,每次去读取。大家也可以将其存在缓存数据库也是可以的。

这里还需要注意下面几点

  • 在发送消息的时数据是JSON格式,JOSN中key的双引号是必须要的,要不然可能就会报{"errcode":40003,"errmsg":"invalid openid hint: [4zOata0077ge25]"}这个错误。 稍不留神就可能掉坑儿, 哈哈。
  • 注意 app.js 中对post消息的处理,处理完成后需要需要返回一个 success 给微信服务器,否则可能出现: 除了自动回复的消息外还会有个"该小程序提供的服务出现故障,请稍后再试"的字样。
image.png

服务器收到请求必须做出下述回复,这样微信服务器才不会对此作任何处理,并且不会发起重试,否则,将出现严重的错误提示。所以需要添加 ctx.body = 'success'; 返回值。 其实这里官网也有说明,详见如下:
1、直接回复success(推荐方式)
2、直接回复空串(指字节长度为0的空字符串,而不是结构体中content字段的内容为空)
一旦遇到以下情况,微信都会在小程序会话中,向用户下发系统提示“该小程序客服暂时无法提供服务,请稍后再试”:
1、开发者在5秒内未回复任何内容
2、开发者回复了异常数据。

到此我们就处理了小程序客服中的文本消息,其他还有一些类型的消息也是类似的,只是消息格式类型有所不同。在调试的时候我发现微信新增了小程序文本链接消息

 const replyMsg = `<a href="http://www.qq.com" data-miniprogram-appid="wxde9a5002adee16bd" data-miniprogram-path="/pages/login/code">点击跳小程序</a>`;
miniapp.sendTextMessage(FromUserName, replyMsg);

展示的效果么就像下面这样:


image.png

剩下的时间就可以补充其他(图文消息、图片消息、小程序消息)的处理了,同时关于客服消息还有一个转发的功能。我们这里做的只不过是一个固定的消息自动回复,毕竟还是不够智能。所有有些客服问题还是得转到人工处理,具体可以参考消息转发

参考阅读

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,594评论 18 139
  • 转载链接 注:本文转载知乎上的回答 作者:初雪 链接:https://www.zhihu.com/question...
    pengshuangta阅读 28,475评论 9 295
  • 题 图|https://pixabay.com 摄影师|nguyenhuynhmai 如果不是上班的路上,看到各中...
    xuzizzz阅读 191评论 0 0
  • 2017年8月7日 星期一 晴转多云 周一,忙碌却又充实的周一,早堵晚高峰的周一,也是一周崭新的开始,经过...
    少年理想国阅读 239评论 0 0
  • 说到自己跟自己关系,主要是说我们怎么样爱自己和接纳自己,我们自己跟自己的关系其实是所有关系的核心,所有的人...
    明心黄阅读 263评论 0 1