当前位置:   article > 正文

手把手教你搭建微信聊天机器人系列(四):多轮对话支持_微信bot 聊天机器人

微信bot 聊天机器人

上一章,我们搭建对接文心一言(ERNIE-Bot大模型)接口的微信聊天机器人,但只支持一轮对话。这一章我们加入数据持久化的逻辑,以便保持上下文,防止服务重启造成的聊天记录丢失。

数据持久化有很多方式,比较好的一种方式是使用数据库,方便扩展功能。eggjs可以使用Sequelize(ORM框架)对数据库进行操作(官网文档点这里)。我们使用目前比较流行的PostgreSQL数据库(以下简称pg)。

PostgreSQL数据库安装

 pg数据库安装文件从官网上下载,点击Download the installer

我选择版本13 x86-64(注:本人的Navicat版本比较低,安装版本16连接不上)

下载完开始安装

 pgAdmin是数据库客户端,如果你机子上已经装了Navicat,也可以不用装pgAdmin。

 

 数据文件所在路径

 

 数据库访问密码,我们设置为pg-ernic

 

 数据库服务访问端口5432

 后面一路next就行

 创建数据库ernic

我们打开Navicat,配置一下数据库连接。

连接名 localhost_pgsql(可自定义)

主机 localhost

端口5432

初始数据库postgres

用户名postgres

密码pg-ernic

配置完,点击左下角【测试连接】,如果正确就会提示连接成功。点击确定。

        双击侧边栏localhost_pgsql,可以逐个展开,我们可以看到下面已经有默认的数据库postgres,目前里面没有任何表。我们待会可以用eggjs的Sequelize(ORM框架)进行建表。

 

 egg-sequelize配置

plugin.js

我们打开config/plugin.js,增加一下插件

 plugin.js代码如下:

  1. 'use strict';
  2. /** @type Egg.EggPlugin */
  3. module.exports = {
  4. sequelize: {
  5. enable: true,
  6. package: 'egg-sequelize',
  7. },
  8. };

config.default.js

config.default.js中加入数据库连接配置,还有联系上下文的配置。这里稍微扩展一下,会话接口联系上下文会导致输入的数据变多,接口计费的规则。我们要对上下文的时间做个限制,比如超过1个小时的聊天记录不再计入传参数据。

  1. config.sequelize = { //Sequelize 数据库配置
  2. dialect: 'postgres',
  3. database: 'postgres',
  4. host: '127.0.0.1',
  5. port: '5432',
  6. username: 'postgres',
  7. password: 'pg-ernic',
  8. timezone: '+08:00', // 数据库时间加8小时
  9. define: {
  10. freezeTableName: true, //使用自己配置的表名,避免sequelize自动将表名转换为复数
  11. createdAt: false, //不默认加创建时间字段:
  12. updatedAt: false, //不默认加修改时间字段:
  13. }
  14. };
  15. //配置wechat
  16. config.wechat = {
  17. manageName: '蓝莲花', //管理员昵称 登录成功会发消息给管理员
  18. relaContextStatus: true, //需要联系上下文,会导致输入文字变多,接口费用增加
  19. relaContextHours: 1 //需要联系上下文 几个小时前的历史消息
  20. }

表模型的建立

从上一章,我们知道要维护两种数据的持久化,一个是access_token,一个是聊天记录。按eggjs的规范,需要创建两个对象模型定义文件。定义好模型后,项目运行起来就可以自动建表。我们在app下创建model目录,新增access_token.js和chat_record.js两个文件。

access_token.js

代码如下:

  1. 'use strict';
  2. module.exports = app => {
  3. const Sequelize = app.Sequelize;
  4. const moment = require('moment');
  5. const AccessToken = app.model.define('access_token', {
  6. id: {
  7. type: Sequelize.INTEGER,
  8. primaryKey: true,
  9. autoIncrement: true
  10. },
  11. access_token: {
  12. type: Sequelize.STRING(200),
  13. comment: ''
  14. },
  15. created_at: {
  16. type: Sequelize.DATE,
  17. allowNull: true,
  18. defaultValue: Sequelize.NOW,
  19. get() {
  20. return moment(this.getDataValue('created_at')).format('YYYY-MM-DD HH:mm:ss');
  21. },
  22. },
  23. updated_at: {
  24. type: Sequelize.DATE,
  25. allowNull: true,
  26. defaultValue: Sequelize.NOW,
  27. get() {
  28. return moment(this.getDataValue('updated_at')).format('YYYY-MM-DD HH:mm:ss');
  29. },
  30. },
  31. }, {
  32. comment: 'AccessToken表'
  33. });
  34. return AccessToken;
  35. };

这里简单介绍一下access_token模型的字段。 

access_token字段用于记录access_token数据。

updated_at字段用于记录access_token更新时间。

后续我们可以根据updated_at字段值来判断是否超过有效期。

 chat_record.js

代码如下:

  1. 'use strict';
  2. module.exports = app => {
  3. const Sequelize = app.Sequelize;
  4. const moment = require('moment');
  5. const ChatRecord = app.model.define('chat_record', {
  6. id: {
  7. type: Sequelize.INTEGER,
  8. primaryKey: true,
  9. autoIncrement: true
  10. },
  11. room_id: {
  12. type: Sequelize.STRING(100),
  13. comment: '群聊id'
  14. },
  15. talker_id: {
  16. type: Sequelize.STRING(100),
  17. comment: '对话者id'
  18. },
  19. talker_name: {
  20. type: Sequelize.STRING(100),
  21. comment: '对话者昵称'
  22. },
  23. send_content: {
  24. type: Sequelize.TEXT,
  25. comment: '发送文字'
  26. },
  27. receive_content: {
  28. type: Sequelize.TEXT,
  29. comment: '接收文字'
  30. },
  31. status: {
  32. type: Sequelize.BOOLEAN,
  33. comment: '是否可用',
  34. defaultValue: true
  35. },
  36. created_at: {
  37. type: Sequelize.DATE,
  38. allowNull: true,
  39. defaultValue: Sequelize.NOW,
  40. get() {
  41. return moment(this.getDataValue('created_at')).format('YYYY-MM-DD HH:mm:ss');
  42. },
  43. },
  44. updated_at: {
  45. type: Sequelize.DATE,
  46. allowNull: true,
  47. defaultValue: Sequelize.NOW,
  48. get() {
  49. return moment(this.getDataValue('updated_at')).format('YYYY-MM-DD HH:mm:ss');
  50. },
  51. },
  52. deleted_at: {
  53. type: Sequelize.DATE,
  54. allowNull: true,
  55. get() {
  56. return moment(this.getDataValue('deleted_at')).format('YYYY-MM-DD HH:mm:ss');
  57. },
  58. },
  59. }, {
  60. comment: '对话记录表'
  61. });
  62. return ChatRecord;
  63. };

这里简单介绍一下聊天记录模型的字段。微信聊天有两种场景:

1、私聊

我们需要记录用户的对话者id,以便区分聊天记录。

2、群聊

我们需要同时记录群聊id和对话者id,以便区分聊天记录。 

app.js的调整

我们需要有个方法app.model.sync来维护model对应的表

  1. module.exports = app => {
  2. app.beforeStart(async () => {
  3. // 应用会等待这个函数执行完成才启动
  4. console.log("==app beforeStart==");
  5. await app.model.sync({ //Sequelize 模型配置
  6. // force: true, // 默认false 为不覆盖 true会删除表再创建(如果有生产业务数据非常危险,不建议打开);
  7. alter: true // 默认true可以 添加或删除字段;
  8. });
  9. });
  10. app.ready(async () => {
  11. console.log("==app ready==");
  12. let ctx = app.createAnonymousContext();
  13. await ctx.service.ernie.checkAccessToken(); //检查AccessToken
  14. await ctx.service.wechat.startBot(); //初始化BOT
  15. })
  16. app.beforeClose(async () => {
  17. console.log("==app beforeClose==");
  18. })
  19. };

我们将access_token的维护交给一个定时任务,每天执行一次,当到了29天的时候,刷新一下数据。定时任务需要在app下新增目录schedule,新增task.js文件。

定时任务task.js

  task.js代码如下:

  1. module.exports = {
  2. schedule: {
  3. interval: '1d', // 1 天间隔
  4. type: 'all', // 指定所有的 worker 都需要执行
  5. immediate: false, //是否项目启动就执行一次定时任务
  6. },
  7. async task(ctx) { //函数名task不能改
  8. await ctx.service.ernie.checkAccessToken(); //检查AccessToken
  9. },
  10. };

service的调整

ernie.js

ernie.js需要一个方法checkAccessToken,用于检查access_token的有效性,全部代码如下:

  1. const {
  2. Service
  3. } = require('egg');
  4. const moment = require('moment');
  5. class ErnieService extends Service {
  6. async getAccessToken() {
  7. console.log('===================ErnieService getAccessToken=====================');
  8. let ctx = this.ctx;
  9. try {
  10. const res = await ctx.curl(
  11. `https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=${ctx.app.config.ernie.client_id}&client_secret=${ctx.app.config.ernie.client_secret}`, {
  12. method: 'GET',
  13. rejectUnauthorized: false,
  14. data: {},
  15. headers: {},
  16. timeout: 30000,
  17. contentType: 'json',
  18. dataType: 'json',
  19. })
  20. console.log(res)
  21. if (res.data.access_token) {
  22. console.log('access_token', ctx.app.config.ernie.access_token)
  23. return res.data.access_token;
  24. }
  25. } catch (error) {
  26. console.log(error)
  27. }
  28. return null;
  29. }
  30. async sendMsg(msg) {
  31. console.log('===================ErnieService sendMsg=====================');
  32. console.log(JSON.stringify(msg));
  33. let ctx = this.ctx;
  34. try {
  35. const res = await ctx.curl(
  36. `https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/completions?access_token=${ctx.app.config.ernie.access_token}`, {
  37. method: 'POST',
  38. rejectUnauthorized: false,
  39. data: {
  40. "messages": msg
  41. },
  42. timeout: 30000,
  43. contentType: 'json',
  44. dataType: 'json',
  45. })
  46. console.log(res)
  47. if (res.data) {
  48. return res.data;
  49. }
  50. return null;
  51. } catch (error) {
  52. console.log(error)
  53. return null;
  54. }
  55. }
  56. async checkAccessToken() {
  57. console.log('===================ErnieService checkAccessToken=====================');
  58. let ctx = this.ctx;
  59. const query = {};
  60. const accessToken = await ctx.model.AccessToken.findOne(query);
  61. if (accessToken) {
  62. console.log('accessToken', accessToken.access_token, accessToken.updated_at);
  63. if (moment(new Date()).diff(moment(accessToken.updated_at), 'days') >= ctx.app.config.ernie.expire_day -
  64. 1) { //提前一天刷新
  65. console.log('accessToken已失效,重新获取中')
  66. const accessTokenStr = await ctx.service.ernie.getAccessToken();
  67. if (accessTokenStr) {
  68. await accessToken.update({
  69. accessToken: accessTokenStr,
  70. updated_at: moment().format('YYYY-MM-DD HH:mm:ss')
  71. });
  72. ctx.app.config.ernie.access_token = accessTokenStr;
  73. console.log('accessToken更新成功');
  74. } else {
  75. console.log('accessToken获取失败');
  76. }
  77. } else {
  78. ctx.app.config.ernie.access_token = accessToken.access_token;
  79. console.log('accessToken在有效期内');
  80. }
  81. } else {
  82. console.log('accessToken不存在');
  83. const accessTokenStr = await ctx.service.ernie.getAccessToken();
  84. if (accessTokenStr) {
  85. const queryData = await ctx.model.AccessToken.create({
  86. access_token: accessTokenStr
  87. });
  88. ctx.app.config.ernie.access_token = accessTokenStr;
  89. console.log('accessToken更新成功');
  90. } else {
  91. console.log('accessToken获取失败');
  92. }
  93. }
  94. }
  95. }
  96. module.exports = ErnieService;

wechat.js

wechat.js需要对聊天记录进行读取和存储,用于保持上下文,全部代码如下:

  1. const {
  2. Service
  3. } = require('egg');
  4. const {
  5. WechatyBuilder,
  6. ScanStatus
  7. } = require("wechaty");
  8. const qrcode = require("qrcode-terminal");
  9. const Sequelize = require('sequelize');
  10. const Op = Sequelize.Op;
  11. let ctx;
  12. let wechaty;
  13. let startStatus = false;
  14. const onMessage = async (message) => {
  15. console.log(`收到消息: ${message}`);
  16. const room = message.room() // 是否是群消息
  17. if (!room || await message.mentionSelf()) { //如果是私聊或者群聊@我
  18. if (message.type() === wechaty.Message.Type.Text) {
  19. const sendContent = await message.text();
  20. if (sendContent) {
  21. try {
  22. let room_id = '';
  23. if (room) { //如果是群聊
  24. room_id = room.id;
  25. console.log(`room_id: ${room_id}`);
  26. }
  27. const talker = message.talker();
  28. const talker_id = talker.id;
  29. const talker_name = talker.name();
  30. console.log(`talker_id: ${talker_id}`);
  31. console.log(`${talker_name}: ${sendContent}`);
  32. let msgRecord = [];
  33. if (ctx.app.config.wechat.relaContextStatus) { //需要联系上下文
  34. let query = {
  35. where: {
  36. created_at: {
  37. [Op.gte]: Sequelize.literal(
  38. `NOW() - (INTERVAL '${ctx.app.config.wechat.relaContextHours} HOUR')`
  39. ),
  40. },
  41. status: true
  42. },
  43. attributes: {
  44. exclude: ['deleted_at', 'status']
  45. }
  46. };
  47. if (room_id) {
  48. query.where.room_id = room_id;
  49. } else {
  50. query.where.talker_id = talker_id;
  51. }
  52. const queryData = await ctx.model.ChatRecord.findAll(query);
  53. for (let i of queryData) {
  54. msgRecord.push({
  55. "role": "user",
  56. "content": i.send_content
  57. });
  58. msgRecord.push({
  59. "role": "assistant",
  60. "content": i.receive_content
  61. });
  62. }
  63. }
  64. msgRecord.push({
  65. "role": "user",
  66. "content": sendContent
  67. });
  68. let res = await ctx.service.ernie.sendMsg(msgRecord);
  69. if (res) {
  70. if (res.error_code) {
  71. message.say(JSON.stringify(res));
  72. console.log(`报错: ${JSON.stringify(res)}`);
  73. } else {
  74. if (res.result) {
  75. message.say(res.result);
  76. console.log(`回复: ${res.result}`);
  77. const chatRecord = await ctx.model.ChatRecord.create({
  78. room_id,
  79. talker_id,
  80. talker_name,
  81. send_content: sendContent,
  82. receive_content: res.result
  83. });
  84. }
  85. }
  86. }
  87. } catch (error) {
  88. console.log(error);
  89. message.say(JSON.stringify(error));
  90. }
  91. }
  92. }
  93. }
  94. };
  95. const onLogout = (user) => {
  96. console.log(`用户 ${user} 退出成功`);
  97. };
  98. const onLogin = async (user) => {
  99. console.log(`用户 ${user} 登录成功`);
  100. const contact = await wechaty.Contact.find({
  101. name: ctx.app.config.wechat.manageName
  102. })
  103. if (contact) {
  104. await contact.say('微信机器人服务初始化完毕!')
  105. } else {
  106. console.error('未找到管理员微信')
  107. }
  108. };
  109. const onError = console.error;
  110. const onScan = (code, status) => {
  111. // status: 2代表链接等待调用,3代表链接已打开,这个链接实际上是提供一个登录的二维码供扫描
  112. if (status === ScanStatus.Waiting) {
  113. // status: 2代表等待,3代表扫码完成
  114. qrcode.generate(code, {
  115. small: true
  116. }, console.log)
  117. }
  118. };
  119. class WechatService extends Service {
  120. async startBot() {
  121. console.log('===================WechatService startBot=====================');
  122. ctx = this.ctx;
  123. if (startStatus && wechaty) {
  124. if (wechaty.isLoggedIn) {
  125. await wechaty.logout();
  126. }
  127. await wechaty.stop();
  128. startStatus = false;
  129. wechaty = null;
  130. }
  131. wechaty = await WechatyBuilder.build();
  132. wechaty
  133. .on("scan", onScan)
  134. .on("login", onLogin)
  135. .on("logout", onLogout)
  136. .on("error", onError)
  137. .on("message", onMessage);
  138. await wechaty.start();
  139. startStatus = true;
  140. }
  141. }
  142. module.exports = WechatService;

最后,我们要安装一下组件

  1. npm i -s egg-sequelize
  2. npm i -s pg
  3. npm i -s pg-hstore

然后我们启动项目,微信扫码登录后,管理员会立即收到机器人发的消息(前提是已经加过机器人为好友,并且昵称为config.wechat.manageName中设置的内容)。然后就可以连续对话了。

        至此,本系列主体功能都已经结束了。看看后续如果还有什么内容,我会继续补充。 

        本章完整代码在这里下载。运行前请配置好config/config.default.js里面config.ernie下的client_id和client_secret配置项。 微信登录二维码如有显示问题请参看第二章-微信登录二维码问题处理

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/笔触狂放9/article/detail/708292
推荐阅读
相关标签
  

闽ICP备14008679号