在去年年二月份的时候自己用别人写的项目弄过一个微信机器人,也写了一篇文章,非常鸡肋,不能自己改源码,调用
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
:这个是在代码推送后,在服务器上执行命令,使用forever
让node
程序永久执行,在代码中会生成./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/
至此,只需要提交代码后,打开链接,重新扫码登录即可
看下效果图吧,谢谢大家~