过年期间使用 wechaty + chatGPT 做个微信聊天机器人定时给大家发祝福吧(含防撤回以及服务器部署)

在去年年二月份的时候自己用别人写的项目弄过一个微信机器人,也写了一篇文章,非常鸡肋,不能自己改源码,调用 chatGPT 的也一直报错。后面自己研究 wechaty,才发现这玩意原来这么好用,而且还这么简单。所有就自己从0写一个吧。

本篇文章可以让你学会:

  • 学会使用 wechaty + node 去定制化自己的微信机器人,自动回复防撤回定时发送群消息
  • 通过 GITHUB ACTION 将代码直接推送到服务器,自动在服务器执行终端命令,然后将生成的二维码地址保存到服务器的固定文件中。
  • 这番操作下来,只需提交代码后,等待 GITHUB ACTION 跑完,然后打开这个固定文件的地址,复制里面的内容扫码登录即可。

部署阶段需要大家有一台自己的服务器,阿里云腾讯云那些,都只需要几十块一年,很便宜,没有服务器的也可以在本地测试玩玩。

开搞,先写项目吧,可以先下载项目代码后再看文章,代码地址

项目初始化

csharp 复制代码
# 初始化
$ npm init

# 安装依赖
$ yarn add axios dayjs node-schedule qrcode-terminal wechaty wechaty-puppet-wechat

编写入口文件

思路:

  • wechaty-puppet-wechat 处理图片视频的时候需要这个

  • wechaty 三个回调方法:

    • scan: 扫码成功后的回调,这里我们需要把生成的二维码地址写入到 ./setenv.sh 这个文件中,后面会通过 GITHUB ACTION 去读取这里面的内容,然后写入到我指定的固定文件中,后面打开这个固定文件即可扫码登录,比如我的文件固定位置是 www/wwwroot/junfeng530.xyz/xx.txt,这样我只需要每次都打开https://junfeng530.xyz/xx.txt 这个地址扫码登录就行了

    • login: 登陆成功后会返回当前登录人,我们需要根据当前登录人与群聊中 at 对象匹配,才去回答对方的问题。同时在这里处理定时发送群消息的逻辑 sendBlessing(bot);

    • message: 消息接收的回调,所有消息都会在这里监听到,我们后面需要区分私聊,群聊然后做处理。

新增 src 目录,新增 src/main.js 入口文件

javascript 复制代码
import { sendBlessing } from "./event/sendMsgOntime.js";
import qrcode from "qrcode-terminal";
import { WechatyBuilder } from "wechaty";
const bot = WechatyBuilder.build({
  puppet: "wechaty-puppet-wechat4u",
  puppetOptions: {
    uos: true,
  },
});

async function main() {
  bot
    .on("scan", (c, status) => {
      // status: 2代表等待,3代表扫码完成
      status === 2 && qrcode.generate(c, { small: true }, console.log);
      // 将url 写入 ./setenv.sh ,后面 在 ci 中获取
      const url = `https://wechaty.js.org/qrcode/${encodeURIComponent(c)}`;
      const scriptContent = `export LOGIN_URL=${url}\n`;
      fs.writeFileSync("./setenv.sh", scriptContent);

      console.log(`Scan QR Code to login: ${status}\n${url}`);
    })
    .on("login", (user) => {
      // 保存当前登录人
      messageBot.setBotName(user.name());
      console.log(`用户 ${user} 登录成功`);
      // 定时发送群聊消息
      sendBlessing(bot);
    })
    .on("message", (message) => {
      // 处理消息
      messageBot.handleMessage(message, bot);
      console.log(`收到消息: ${message}`);
    });
  try {
    await bot.start();
  } catch (e) {
    console.error(
      `⚠️ Bot start failed, can you log in through wechat on the web?: ${e}`
    );
  }
}
main();

调试直接执行

bash 复制代码
node src/mian.js

编写数据池

写完入口,先来处理数据吧,我这里的思路是根据群聊,私聊去管理数据

新增 src/dataPool.js

kotlin 复制代码
export class DataPool {
  constructor() {
    this.data = {
      privateData: {},
      roomData: {},
    };
  }

  // 添加私聊数据
  addPrivateData(key, value) {
    this.data.privateData[key] = value;
  }
  // 添加群聊数据
  addroomData(key, value) {
    this.data.roomData[key] = value;
  }
  // 删除群聊数据
  deleteRoomData(key) {
    delete this.data.roomData[key];
  }
  //   获取私聊数据
  getPrivateData(key) {
    return this.data.privateData[key];
  }
  // 删除私聊数据
  deletePrivateData(key) {
    delete this.data.privateData[key];
  }
  // 获取群聊数据
  getRoomData(key) {
    return this.data.roomData[key];
  }
  // 获取所有私聊数据
  getAllPrivateData() {
    return this.data.privateData;
  }
  // 获取所有群聊数据
  getAllRoomData() {
    return this.data.roomData;
  }
}

编写定时发送群消息

在入口文件的 login 回调会执行我们的方法 sendBlessing(bot);,新增 event/sendMsgOntime.js

下面是我自己打算在某些群聊定时发送消息的代码,哈哈,献丑了~

javascript 复制代码
import { askChatGPT } from "../utils.js";

import schedule from "node-schedule";

// 定时向群聊发消息
export const sendBlessing = async (bot) => {
  // 定义日期和事件
  const dates = {
    "2024-02-09T09:00:00": "除夕",
    "2024-02-09T16:00:00": "除夕",
    "2024-02-09T23:59:59": "除夕夜",
    "2024-02-10T09:00:00": "年初一",
    "2024-02-10T18:00:00": "年初一",
    "2024-02-11T09:00:00": "年初二",
    "2024-02-11T18:00:00": "年初二",
    "2024-02-12T09:00:00": "年初三",
    "2024-02-12T18:00:00": "年初三",
    "2024-02-13T09:00:00": "年初四",
    "2024-02-13T18:00:00": "年初四",
    "2024-02-14T09:00:00": "年初五",
    "2024-02-14T18:00:00": "年初五",
    "2024-02-15T09:00:00": "年初六",
    "2024-02-15T18:00:00": "年初六",
    "2024-02-16T09:00:00": "年初七",
    "2024-02-16T18:00:00": "年初七",
    "2024-02-17T09:00:00": "年初八",
    "2024-02-17T18:00:00": "年初八",
    "2024-02-18T09:00:00": "年初九",
    "2024-02-18T18:00:00": "年初九",
  };

  const roomList = ["群聊名称1", "群聊名称2"];
  roomList.forEach(async (item) => {
    const targetRoom = await bot.Room.find({ topic: item });

    if (targetRoom) {
      await targetRoom.ready(); // 确保群聊信息已加载
      Object.entries(dates).forEach(([date, label]) => {
        schedule.scheduleJob(`${date}`, async () => {
          console.log("发送新年祝福...");
          const answer = await generateBlessingMessage(label);
          await targetRoom.say(answer); // 自定义你的祝福语
        });
      });
    } else {
      console.log("未找到群聊");
    }
  });
};

async function generateBlessingMessage(label) {
  const message = `今天是2024年的${label},龙年,给大家写一段祝福语,要求喜庆,开头要说今天是${label}啦,然后说一些祝福大家新年快乐,事业顺利,身体健康,心想事成,虎年吉祥之类的话,不需要介绍自己,语气尽量生动活泼,每个段落都要穿插一些表情,段落不得少于三段,字数不得少于两百字`;
  const answer = await askChatGPT(message);
  return answer;
}

编写消息处理

新增 src/bot.js

消息类型对应

arduino 复制代码
// 消息类型对应
export const MessageType = {
  Unknown: 0,
  Attachment: 1, // Attach(6),
  Audio: 2, // Audio(1), Voice(34)
  Contact: 3, // ShareCard(42)
  ChatHistory: 4, // ChatHistory(19)
  Emoticon: 5, // Sticker: Emoticon(15), Emoticon(47)
  Image: 6, // Img(2), Image(3)
  Text: 7, // Text(1)
  Location: 8, // Location(48)
  MiniProgram: 9, // MiniProgram(33)
  GroupNote: 10, // GroupNote(53)
  Transfer: 11, // Transfers(2000)
  RedEnvelope: 12, // RedEnvelopes(2001)
  Recalled: 13, // 撤回(10002)
  Url: 14, // Url(5)
  Video: 15, // Video(4), Video(43)
  Post: 16, // Moment, Channel, Tweet, etc
};

思路:

  • 消息进来后,我们可以拿到

    • 消息类型:msg.type()
    • 消息发送人:msg.talker()
    • 消息内容:msg.text()
    • 消息群聊信息:msg.room()
  • 根据是否是群聊消息去区分,保存消息内容,整理所有数据,并且删除超过三分钟的消息,因为超过三分钟的消息的就不能撤回了,当然如果后期你想做群聊统计这个就自己写了。
    *

    arduino 复制代码
    //  撤回的消息就不做整理了
    messageType !== 13 &&
    (await handleDataPool(room, msg, talker, messageType, text, dataPool));
  • 接下来就是分别对群聊,私聊做处理

    • 对群聊的 at 的文本类型,直接调用 chatGPT 回答,对于用户撤回的消息,根据 msgid 在我们保存的数据池中找到对应的消息,读取后直接返回回去,强硬的防撤回,哈哈~
    • 对于私聊的文本类型,直接调用 chatGPT 回答,撤回逻辑跟群聊一致
ini 复制代码
import { DataPool } from "./dataPool.js";
import { filterMessage, askChatGPT, handleDataPool } from "./utils.js";

// 数据池
const dataPool = new DataPool();

export class MessageBot {
  // 当前用户
  botName = "";

  // 设置当前用户
  setBotName = (name) => {
    this.botName = name;
  };

  // 处理消息
  handleMessage = async (msg) => {
    const talker = msg.talker();
    // 内容
    const text = msg.text();
    // 群聊
    const room = msg.room();
    // 消息类型
    const messageType = msg.type();

    console.log("消息类型", messageType);
    console.log("talker", talker);
    console.log("text", text);
    console.log("msg", msg);
    // 根据类型添加数据池
    messageType !== 13 &&
      (await handleDataPool(room, msg, talker, messageType, text, dataPool));
    console.log("数据整理", dataPool.getAllRoomData());

    // 过滤数据不处理
    if (filterMessage(talker, messageType, text)) return;

    // 处理群聊
    if (room) {
      // 处理 at 自己的文本消息
      if (text.includes(`@${this.botName}`) && messageType === 7) {
        const answer = await askChatGPT(text);
        const gptMessage = `@${talker.name()} \n------\n ${answer}`;
        msg.say(gptMessage);
        return;
      }

      // 处理群聊撤回消息
      if (messageType === 13) {
        try {
          const msgId = text.split("<msgid>")[1].split("</msgid>")[0];
          // 找到撤回的消息
          const recalledMessage = dataPool
            .getRoomData(room.id)
            .message.filter((item) => item.msgId === msgId)[0];

          // 返回撤回的文本
          if (recalledMessage.messageType === 7) {
            msg.say(
              `这是${talker.name()}撤回的消息 \n------\n ${
                recalledMessage.content
              }`
            );
          }

          // 返回撤回的图片以及视频
          if (
            recalledMessage.messageType === 6 ||
            recalledMessage.messageType === 15
          ) {
            msg.say(
              `这是${talker.name()}撤回的${
                recalledMessage.messageType === 6 ? "图片" : "视频"
              }`
            );
            msg.say(recalledMessage.content);
          }
        } catch (err) {
          console.log("失败");
        }
      }
    }

    // 处理私聊
    if (!room) {
      // 文本直接走chatGPT
      if (messageType === 7) {
        const answer = await askChatGPT(text);
        msg.say(answer);
        return;
      }

      // 处理撤回
      if (messageType === 13) {
        try {
          const msgId = text.split("<msgid>")[1].split("</msgid>")[0];
          // 找到撤回的消息
          const recalledMessage = dataPool
            .getPrivateData(talker.id)
            .message.filter((item) => item.msgId === msgId)[0];
          console.log("recalledMessage", recalledMessage);
          // 返回撤回的文本
          if (recalledMessage.messageType === 7) {
            msg.say(`这是您撤回的消息 \n------\n ${recalledMessage.content}`);
          }
          // 返回撤回的图片
          if (
            recalledMessage.messageType === 6 ||
            recalledMessage.messageType === 15 ||
            recalledMessage.messageType === 2
          ) {
            msg.say(
              `这是您撤回的${
                recalledMessage.messageType === 6 ? "图片" : "视频"
              }`
            );
            msg.say(recalledMessage.content);
          }
        } catch (err) {
          console.log("失败");
        }
        return;
      }
    }
  };
}

编写工具方法

前面一些代码使用了封装好的方法,这里将代码贴上,新增 src/utils.js

javascript 复制代码
import { BLOCK_WORDS } from "./config.js";
import axios from "axios";
import { hostname, apiKey, model } from "./config.js";

import dayjs from "dayjs";

// 消息类型对应
export const MessageType = {
  Unknown: 0,
  Attachment: 1, // Attach(6),
  Audio: 2, // Audio(1), Voice(34)
  Contact: 3, // ShareCard(42)
  ChatHistory: 4, // ChatHistory(19)
  Emoticon: 5, // Sticker: Emoticon(15), Emoticon(47)
  Image: 6, // Img(2), Image(3)
  Text: 7, // Text(1)
  Location: 8, // Location(48)
  MiniProgram: 9, // MiniProgram(33)
  GroupNote: 10, // GroupNote(53)
  Transfer: 11, // Transfers(2000)
  RedEnvelope: 12, // RedEnvelopes(2001)
  Recalled: 13, // 撤回(10002)
  Url: 14, // Url(5)
  Video: 15, // Video(4), Video(43)
  Post: 16, // Moment, Channel, Tweet, etc
};

// 过滤消息
export const filterMessage = (talker, messageType, text) => {
  return (
    talker.self() ||
    // TODO: add doc support
    !(messageType === 1 || messageType === 7 || messageType === 13) ||
    talker.name() === "微信团队" ||
    // 语音(视频)消息
    text.includes("收到一条视频/语音聊天消息,请在手机上查看") ||
    // 红包消息
    text.includes("收到红包,请在手机上查看") ||
    // Transfer message
    text.includes("收到转账,请在手机上查看") ||
    // 位置消息
    text.includes("/cgi-bin/mmwebwx-bin/webwxgetpubliclinkimg") ||
    // 聊天屏蔽词
    BLOCK_WORDS.find((word) => text.includes(word))
  );
};

// 询问ChatGPT
export const askChatGPT = (msg) => {
  return new Promise(async (resolve, reject) => {
    try {
      // 这里不做上下文关联
      const res = await axios({
        url: `https://${hostname}/v1/chat/completions`,
        method: "POST",
        data: {
          model: model,
          messages: [
            {
              role: "user",
              content: msg,
            },
          ],
        },
        headers: {
          Authorization: `Bearer ${apiKey}`,
        },
      });
      if (res?.data.choices && res?.data?.choices.length) {
        resolve(res?.data?.choices[0].message.content);
      } else {
        reject("");
      }
    } catch (error) {
      console.log("询问失败");
      reject("");
    }
  });
};

// 处理数据池的数据
export const handleDataPool = async (
  room,
  msg,
  talker,
  messageType,
  text,
  dataPool
) => {
  // 群聊
  if (room) {
    const roomName = await room.topic();
    if (!dataPool.getRoomData(room.id)) {
      dataPool.addroomData(room.id, {
        id: "",
        message: [],
      });
    }
    // 处理图片
    let roomContentObj = text;
    if (messageType === 6 || messageType === 15) {
      roomContentObj = await msg.toFileBox(); // 获取图片的 FileBox
    }
    // 处理群聊数据
    dataPool.addroomData(room.id, {
      id: room.id,
      name: roomName,
      message: [
        ...dataPool.getRoomData(room.id).message,
        {
          talkerId: talker.id,
          talkerName: talker.name(),
          content: roomContentObj,
          time: dayjs(msg.timestamp).format("YYYY-MM-DD HH:mm:ss"),
          messageType,
          room: room.id,
          msgId: msg.id,
        },
      ],
    });

    // 删除超过三分钟的消息
    Object.keys(dataPool.getAllRoomData()).forEach((key) => {
      const currentItem = dataPool.getRoomData(key);
      if (!currentItem || !currentItem?.message || currentItem?.length) {
        // 删除空数据
        dataPool.deleteRoomData(key);
        return;
      }
      currentItem.message.forEach((item, index) => {
        if (dayjs().diff(dayjs(item.time), "minute") > 3) {
          // 根据下标删除对应项
          dataPool.getRoomData(key).message.splice(index, 1);
        }
      });
    });
  }

  // 私聊
  if (!room) {
    if (!dataPool.getPrivateData(talker.id)) {
      dataPool.addPrivateData(talker.id, {
        id: "",
        message: [],
      });
    }
    // 处理图片
    let privateContentObj = text;
    if (messageType === 6 || messageType === 15 || messageType === 2) {
      privateContentObj = await msg.toFileBox();
    }
    dataPool.addPrivateData(talker.id, {
      id: talker.id,
      name: talker.name(),
      message: [
        ...dataPool.getPrivateData(talker.id).message,
        {
          talkerId: talker.id,
          talkerName: talker.name(),
          content: privateContentObj,
          time: dayjs(msg.timestamp).format("YYYY-MM-DD HH:mm:ss"),
          messageType,
          msgId: msg.id,
        },
      ],
    });

    // 删除超过三分钟的消息
    Object.keys(dataPool.getAllPrivateData()).forEach((key) => {
      const currentItem = dataPool.getPrivateData(key);
      if (
        !currentItem ||
        !currentItem?.message ||
        !currentItem?.message.length
      ) {
        // 删除空数据
        dataPool.deletePrivateData(key);
        return;
      }
      currentItem.message.forEach((item, index) => {
        if (dayjs().diff(dayjs(item.time), "minute") > 3) {
          // 根据下标删除对应项
          dataPool.getPrivateData(key).message.splice(index, 1);
        }
      });
    });
  }
};

代码里面有一些是 chatGPT 转发 key 的内容,这里就不写啦,如果对 chatGPT 转发不懂的可以私聊我。

到这里其实项目大致都写好了,接下来处理服务器部署,以及 GITHUB ACTION 的逻辑

代码自动部署

在项目中新增 .github/workflows/main.yml

yaml 复制代码
# https://github.com/marketplace/actions/ssh-deploy

name: Build And Deploy
on:
  push:
    branches: ["main"]
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Setup Node
        uses: actions/setup-node@v1
        with:
          node-version: 16.13.0
      - name: Install Dependencies
        run: yarn
      - name: Deploy
        uses: easingthemes/ssh-deploy@v5.0.0
        with:
          # Private Key
          SSH_PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
          # Arguments to pass to rsync
          # ARGS: # optional, default is -rltgoDz
          # Source directory
          SOURCE: "./"
          EXCLUDE: "/.github/, /.Vscode/, .gitignore, /.git/"
          # Remote host
          REMOTE_HOST: ${{ secrets.REMOTE_HOST }}
          # Remote user
          REMOTE_USER: ${{ secrets.REMOTE_USERNAME }}
          # Target directory22
          TARGET: "/jiang/wechatBot"
          SCRIPT_AFTER: "yarn build"
      - name: run on remote
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.REMOTE_HOST }}
          username: ${{ secrets.REMOTE_USERNAME }}
          password: ${{ secrets.REMOTE_PASSWORD }}
          port: ${{ secrets.REMOTE_PORT }}
         script: |
            cd /jiang/wechatBot // cd 目录
            forever stopall // 先清除之前的所有 forever 进程
            forever start ./src/main.js // 启动当前
            sleep 10 // 等待十秒后往下执行,目的是等待 main.js 进程写入登陆链接到 ./setenv.sh
            source ./setenv.sh // 获取文件的登陆链接
            echo "$LOGIN_URL" > /www/wwwroot/junfeng530.xyz/xx.txt // 将登录链接写入到指
  • easingthemes/ssh-deploy@v5.0.0:这个是为了把代码推送到服务器上
  • appleboy/ssh-action@v1.0.3:这个是在代码推送后,在服务器上执行命令,使用 forevernode 程序永久执行,在代码中会生成 ./setenv.sh 文件,把文件内容写入到固定文件中

需要做的一些前置操作

  • 生成 ssh 密钥
arduino 复制代码
ssh-keygen -t rsa -b 4096 -C 'email'
  • 在服务器中添加 ssh 公匙,id_rsa.pub
bash 复制代码
echo "这里修改为你的公钥内容" >> ~/.ssh/authorized_keys
  • github Action 中添加 PRIVATE_KEY ,写入私匙内容,id_rsa,注意 私钥在 end 末尾加一个空行,否则会报错
  • GITHUB ACTION 中还要添加你服务器的
    • ip 地址:REMOTE_HOST
    • 端口号:REMOTE_PORT
    • 登录用户名:REMOTE_USERNAME
    • 登录密码:REMOTE_PASSWORD

在服务器中安装 forever

bash 复制代码
# 安装 forever
npm install forever -g

# 设置软链,前面是 forever 安装目录
 ln -s  /www/server/nodejs/v16.3.0/lib/node_modules/forever/bin/forever /usr/local/bin/

至此,只需要提交代码后,打开链接,重新扫码登录即可

看下效果图吧,谢谢大家~

相关推荐
惜.己1 分钟前
Jmeter中的配置原件(四)
java·前端·功能测试·jmeter·1024程序员节
EasyNTS3 分钟前
无插件H5播放器EasyPlayer.js网页web无插件播放器vue和react详细介绍
前端·javascript·vue.js
guokanglun26 分钟前
Vue.js动态组件使用
前端·javascript·vue.js
Go4doom29 分钟前
vue-cli3+qiankun迁移至rsbuild
前端
-seventy-39 分钟前
Ajax 与 Vue 框架应用点——随笔谈
前端
我认不到你1 小时前
antd proFromSelect 懒加载+模糊查询
前端·javascript·react.js·typescript
集成显卡1 小时前
axios平替!用浏览器自带的fetch处理AJAX(兼容表单/JSON/文件上传)
前端·ajax·json
焚琴煮鹤的熊熊野火1 小时前
前端垂直居中的多种实现方式及应用分析
前端
我是苏苏1 小时前
C# Main函数中调用异步方法
前端·javascript·c#
转角羊儿2 小时前
uni-app文章列表制作⑧
前端·javascript·uni-app