又到了我们的 MXGA 时间,这次我们把目光放在了微信上。众所周知,微信是个相对封闭的平台,它不像 Telegram 那样提供了机器人的 API,供用户制作一些聊天机器人来增加一些有趣的功能,直到前段时间我从同事那儿了解到了一个叫 Wechaty 的神奇 SDK,你可以通过它实现一个微信聊天机器人。这一下子就激起我的整活欲望,必须得给我的吹水群安排上。于是便有了本文,算是对这次折腾的一个记录。
在开始之前,我们先简单介绍一下 Wechaty。Wechaty 是一个制作聊天机器人(Chatbot)的 SDK,最初是作者为了在微信上做些自动化的任务而做的 side project,后来给开源了出来,再后来随着使用的人变多,慢慢加上了对企业微信,WhatsApp,Gitter 等 IM 服务的支持,最终便成了现在的模样。
Wechaty 的架构大概如下:
如上图:
使用 Wechaty 开始写机器人也非常简单,使用 TypeScript 只需要短短几行便可初始化并启动一个 Wechaty 的机器人:
import {WechatyBuilder} from 'wechaty'
import {PuppetPadlocal} from 'wechaty-puppet-padlocal';
const bot = WechatyBuilder.build({
name: 'myBotName',
puppet: new PuppetPadlocal({
token: myToken,
}),
});
bot
.on('scan', (qrcodeString, status) => console.log(`Scan QR Code to login: ${status} - ${qrcodeString}`))
.on('login', user => console.log(`User ${user} logged in`))
.on('message', message => console.log(`Message: ${message}`))
bot.start()
这里我们使用了 PuppetPadlocal 作为我们的 Puppet,它是基于微信的 iPad 协议的 Puppet 实现,也是目前使用最广泛的微信 Puppet。但它是一个收费的 Puppet,所以你需要付费购买它的 token 才能使用。
初始化机器人时我们传入了相应的回调去响应对应的事件:
scan
:这个会在机器人需要扫码登陆的时候调用到。我们以微信为例,微信并没有机器人的概念,而我们使用 Wechaty 做的微信机器人本质上其实也只是一个普通的个人账号,要让这个微信账号和 Wechaty 关联上需要先进行扫码登录。这里会传入相关的二维码字符串 qrcodeString
,我们需要使用微信扫一下对应的二维码模拟在 iPad 上进行登录。这是一个“一次性”的操作,在没有退出 iPad 登录之前,下次启动机器人都是不需要重复这个步骤的。login
:这个会在机器人登录完成后调用到。和 scan
不同,这个回调每次启动机器人都会被调用到,非常适合在这里添加一些等机器人实际启动后要做的一些初始化设置逻辑。message
:这个会在机器人收到消息后调用到。这里可以说是聊天机器人的核心逻辑所在,机器人可以根据收到消息的内容去进行不同的操作,所以这个事件也可以说是机器人逻辑的入口所在。Wechaty 并不只提供了这几个事件,但是这些足够我们实现一个有趣的 Chatbot 了,更多的事件可以参考相关的文档。
对 Wechaty 有了大概的了解后,我们就可以开始干正事了。
在实际开始之前,需要准备一个微信小号。正如刚刚所说,微信并没有机器人相关的功能,所以微信并不会提供一个机器人账号,我们的微信机器人也不过是一个正常的微信账号,所以需要准备一个小号用来当作机器人来使用。关于如何获取小号的这个问题,就看各位小伙伴大显神通了,我是用家人恰巧闲置的一个手机号注册了一个微信。这里不推荐直接使用自己的大号,首先你也不愿意一直收到被 at 的通知,其次说不定也会被微信风控而封号。
Wechaty 虽然支持诸如 Python,Go 等其它多种编程语言,但是我最终还是选择了从未使用过的 TypeScript,因为 Wechaty 提供了对 TypeScript 第一手的全面支持,并且 Node 的那一套生态也非常成熟,这是一个非常好的学习机会。
首先是安装 Node 环境,我们可以直接一行命令搞定:
curl -o- <https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh> | bash
在成功安装 Node 后,我们就可以开始创建对应的项目:
npm init
跟着上面的命令就可以一步步在当面目录下创建出对应的 Package.json
文件出来,这个文件是用来描述当前 node 项目的,里面包括了项目的名称,版本,依赖等等 metadata。这里和用 Xcode 做 iOS 开发不同,并不需要什么 IDE 专属的项目文件。
然后我们可以通过 Node 来安装 TypeScript:
npm install typescript --save-dev
这里我们将 TypeScript 作为 dev dependency 给引入到了当前项目中。光安装 TypeScript 还不够,我们还需要告诉 TypeScript 如何去编译我们的代码,所以还需要在当前目录新建一个 tsconfig.json
文件来进行配置,这个文件可以类比成 Xcode 中的 Build Setting 部分:
{
"$schema": "<https://json.schemastore.org/tsconfig>",
"display": "Node 18",
"compilerOptions": {
"lib": [
"es2022"
],
"module": "commonjs",
"target": "es2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"outDir": "dist"
},
"include": [
"index.ts",
"lib/**/*"
],
"exclude": [
"node_modules",
"**/*.spec.ts"
]
}
tsconfig.json
文件我是直接在 tsconfig 模版基础上加上了 include
和 exclude
部分,指定了编译时需要包含哪些源码文件,整个项目的文件结构大概如下:
.
├── index.ts
├── config
│ ├── foo.json
│ └── bar.json
├── lib
│ ├── baz.ts
│ ├── qux.ts
├── node_modules
│ ├── ...
├── dist
│ ├── ...
同时把 outDir
设置成了 dist
,也就是最终编译出来的 JavaScript 代码会放到 dist
目录中。此时我们可以对 Package.json
稍作添加:
"scripts": {
"build": "tsc --build",
"start": "node dist/index.js"
},
此时我们就可以通过在终端运行 node run build
以及 node start
命令来编译以及运行我们的项目了。
最后的最后,则是把 Lint 工具给加上。这里直接选用 ESLint:
npm init @eslint/config
运行完后我们可以看到当前目录会新增一个 .eslintrc.json
文件,这个文件用来对 lint 的规则进行配置,这里就不直接放内容了,可以照着文档根据项目的需求按需进行配置。
开发环境已经就位,接下来就可以开始愉快地写代码了。
我把它设计成了一个传统的基于命令的机器人,一个是被动的接受命令去执行操作,另一个则是定时主动执行命令。下面我们一个个来看。
机器人可以接受命令去执行一些操作。我们可以把这个机器人想成是运行在微信上的命令行程序 bot
,我们可以通过给 bot
增加子命令来让它给我们去做各种各样有趣的事情,比如 bot foo
。既然是基于命令的,那我们很容易想到定义一个 Command
接口来表示命令,以方便我们添加新的命令:
import {Message} from 'wechaty';
import {WechatyInterface} from 'wechaty/impls';
export interface MessageCommand {
readonly name: string,
readonly shorthandName?: string,
readonly description: string,
readonly helpMessage: string,
run(args: string[], message: Message, bot: WechatyInterface): Promise<void>
}
每个命令会有自己的名字,缩写,帮组文本等信息,同时最重要的就是 run
方法,这个每个命令具体实现逻辑的入口,这个方法会传入接收到的命令参数,原消息以及机器人实例。
我们必不可少的肯定是一个 help
命令,比如我们可以给机器人发一个 help
,它会返回所有命令的描述(MessageCommand.description
),发送 help xxx
,则会返回 xxx
命令的使用方法(MessageCommand.helpMessage
),如下图:
help
的实现非常简单:
export class HelpMessageCommand implements MessageCommand {
name: string = 'help';
shorthandName?: string = 'h';
description: string = 'Show usage of the bot or comand.';
helpMessage: string = 'Use `help [command]` to show uasage of the command or the bot if [command] is omitted.';
commands: MessageCommand[] = [];
async run(args: string[], message: Message, _bot: WechatyInterface): Promise<void> {
if (args.length == 0) {
WechatyUtil.reply(message, this.fullHelpText(message));
} else {
const command = MessageCommandUtil.commandOf(args[0], this.commands);
if (command) {
WechatyUtil.reply(message, command.helpMessage);
} else {
WechatyUtil.reply(message, 'Command not found.');
}
}
}
private fullHelpText(message: Message): string {
const commandsHelps = this.commands.filter(function(command) {
return MessageCommandUtil.hasPermissionToUse(command, message);
}).map(function(command) {
if (command.shorthandName == undefined) {
return `${command.name}: ${command.description}`;
}
return `${command.name}, ${command.shorthandName}: ${command.description}`;
});
return commandsHelps.join('\\n');
}
}
HelpMessageCommand
在运行的时候会根据 args.length
取判断是返回所有命令的信息,还是只返回特定的命令的信息。
不知道你在群里吹水的时候有没有遇到一种情况,其他人在聊某一个你不了解的话题/名词的时候,大部分时候你可能会问一句:“xxx 是什么”,别人可能也一时半会儿无法向你解释清楚或者无法解释,这个时候就可以让机器人来给你解释:
我们再次很自然地想到可以去通过维基百科或者百度百科的 API 去搜索相关的词条来解答我们的疑惑:
export class BaikeMessageCommand implements MessageCommand {
name: string = 'baike';
shorthandName?: string = 'bk';
description: string = 'Show baike of the query.';
helpMessage: string = 'Use `baike $(query)` to get the baike of the query.';
async run(args: string[], message: Message, _bot: WechatyInterface): Promise<void> {
if (args.length == 0) {
WechatyUtil.reply(message, this.helpMessage);
return;
}
const query = args.join(' ');
const wikipedia = await this.searchWikipedia(query);
if (wikipedia != undefined) {
WechatyUtil.reply(message, wikipedia!);
return;
}
const baidu = await this.serachBaiduBaike(query);
WechatyUtil.reply(message, baidu);
}
private async searchWikipedia(query: string): Promise<string | undefined> {
let url: string;
let headers: AxiosRequestHeaders;
const chineseRegex = /[\\u3040-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\uff66-\\uff9f]/;
if (query.match(chineseRegex)) {
url = `https://zh.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`;
headers = {'Accept-Language': 'zh-cn'};
} else {
url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`;
headers = {};
}
const data = await NetworkUtil.get(url, headers);
if (data) {
return data.extract;
} else {
return undefined;
}
}
private async serachBaiduBaike(query: string): Promise<string> {
const url = `https://baike.baidu.com/api/openapi/BaikeLemmaCardApi?format=json&appid=${appConfig.baikeAppID}&bk_length=600&bk_key=${encodeURIComponent(query)}`;
const data = await NetworkUtil.get(url);
return `${data.abstract}`;
}
}
BaikeMessageCommand
在收到 query
后,会先通过 axios 发起 HTTP 请求通过维基百科的 API 去进行搜索,如果搜不到的话则会 fallback 到使用内容质量稍低的百度百科。这里的一个细节是在搜索维基百科时会根据 query
是否是中文从而设置维基百科的搜索语言为中文,否则默认是搜的英文词条,从而导致没有搜索不到对应的内容。
有了 baike
命令,你就可以在聊天的时候顺带了解到一些各种各样的冷/热知识。
这个命令的灵感来源于推特,其实我们在聊天的时候也会有这样的需求,想把某个“有趣”的发言给做成图片,于是我就给机器人加了个类似功能,只需要引用某条文本消息并给机器人发个 quote
命令,机器人便会生成一张这样的图:
这里我就不贴代码了,因为代码有点长,我讲一下大概的思路。
首先 Wechaty 并不支持获取 Message
引用的 Message
,目前有一个 issue 开着,但还没有什么最新进展。但是我们可以曲线救国,当一条消息引用了别的消息时,Message.text
的值为:
「Cokile: xxxx」
------------------
1234
那我们就可以通过解析这个字符串得到被引用消息的发送方 Cokile
以及被引用消息的内容 xxxx
,有了这两个信息之后我们就能通过 Wechaty 的 API 在群聊里找到名为 Cokile
的 Contact
(单聊也类似的处理逻辑,但是单聊不是自己就是对方),然后可以通过 contact.avatar().toFile()
将对应的头像下载下来,接着我们将头像和 xxxx
传入预定义好的 HTML 模版里,最终通过 puppeteer 把这个 HTML 渲染出来并生成一张截图,最后将截图发送到会话中。
有了命令后,我们自然就需要一个地方来统一解析收到的消息,获取对应的命令名称以及相关的参数并运行这个命令,所以我们又很自然地需要一个 controller
来做这件事:
export class MessageCommandController {
commands: MessageCommand[] = [];
setup() {
this.commands.push(new BaikeMessageCommand());
this.commands.push(new QuoteMessageCommand());
const helpCommand = new HelpMessageCommand();
this.commands.push(helpCommand);
helpCommand.commands = this.commands;
}
async handle(message: Message, bot: WechatyInterface) {
if (appConfig.blacklistUser.includes(message.talker().id)) {
// ignore messages sent by blacklist users, such as WeChat Teams
return;
}
// ignore messages sent by the bot itself
if (message.self()) {
return;
}
const text = await WechatyUtil.removingQuote(message, true);
const room = message.room();
const inGroup = room != undefined;
// ignore messages that mentioning all in group
// a humble check for mention all
if (inGroup && (text.includes('@All') || text.includes('@所有人'))) {
WechatyUtil.reply(message, 'Mentioning all is not supported yet.');
return;
}
const mentionSelf = await message.mentionSelf();
const textWithoutMention = await WechatyUtil.removingQuote(message, false);
// ignore messages not mention the bot in group chat
if (inGroup && !mentionSelf) {
return;
}
const components = textWithoutMention.split(' ');
if (components.length == 0) {
return;
}
const command = MessageCommandUtil.commandOf(components[0], this.commands);
if (command == undefined || !MessageCommandUtil.hasPermissionToUse(command!, message)) {
return;
}
await command?.run(components.slice(1), message, bot);
}
}
controller
代码虽然长了点,但是逻辑也不复杂,里面主要是两个方法,setup
方法会将实现的命令注册上去,另一个则是 handle
方法,它负责一些通用的逻辑,比如过滤掉不关注的用户发送的消息,对 mention 进行处理,然后找到对应的命令并运行。
机器人可以定时主动执行命令,也就是定时命令 CronCommand
。可以类比 unix 下的 cron,这也正是这个命令名字的由来。CronCommand
的定义非常简单:
import {WechatyInterface} from 'wechaty/impls';
export interface CronCommand {
run(bot: WechatyInterface): Promise<void>
}
CronCommand
只有一个 run
方法,每个命令会在这个方法里面通过 node-schedule
启动对应的定时任务,在特定的时间向回话里主动推送消息。
CronCommand
也有对应的 controller
:
import {WechatyInterface} from 'wechaty/impls';
export class CronCommandController {
private commands: CronCommand[] = [];
setup() {
this.commands = [
new BoyCronCommand,
new WeatherCronCommand(),
];
}
async run(bot: WechatyInterface) {
for (const command of this.commands) {
await command.run(bot);
}
}
}
controller
也分为了类似的两个方法,setup
方法会将实现的命令注册上去,另一个则是 run
方法,它负责将所有注册的命令的 run
方法调用一遍。
定时任务一个实用的场景就是每天早上推送天气预报,方便一天的出行:
实现这个功能的代码也很简单:
import {WechatyInterface} from 'wechaty/impls';
import * as scheduler from 'node-schedule';
export class WeatherCronCommand implements CronCommand {
async run(bot: WechatyInterface): Promise<void> {
for (const config of cronConfig.weather) {
const room = await bot.Room.find({topic: 'MyGroupName'});
if (room != undefined) {
scheduler.scheduleJob(`weather-${room.id}`, '0 9 * * *', async function() {
const weather = await WeatherCronCommand.getWeather(config.location);
room?.say(weather);
});
}
}
}
private static async getWeather(location: string): Promise<string> {
const url = `https://devapi.qweather.com/v7/weather/3d?location=${location}&key=${appConfig.weatherToken}`;
const data = await NetworkUtil.get(url);
const daily = data.daily[0];
return [
'今日天气预报:',
`${daily.textDay}`,
`温度:${daily.tempMin}°C - ${daily.tempMax}°C`,
`紫外线强度指数:${daily.uvIndex}`,
`${daily.windDirDay} ${daily.windScaleDay} 级,风速 ${daily.windSpeedDay} 公里/小时`,
].join('\\n');
}
}
机器人每天早上九点会准时获取今天的天气预报并发送到机器人所在的名为 MyGroupName
的群聊中。这里我们选用了和风天气作为数据源,和风天气提供了免费的 API 去获取天气预报,通过传入经纬度即可获取对应地点的当日天气预报。
node-schedule
支持通过 cron 风格的时间调度,也就是我们代码里的 0 9 * * *
,如果对 cron 的时间格式不熟悉的话,可以通过这个网站快速上手。
怎么可以忘了姐妹群看帅哥的需求呢,所以我又给机器人加上了定时推送小红书帅哥的命令:
小红书本身并没有提供 API,但是我们依旧可以曲线救国,先对小红书进行抓包然后模拟小红书的请求,最后抓出来发现搜索是通过 https://edith.xiaohongshu.com/api/sns/v10/search/notes
的请求进行的。
有了抓包的数据,我们就可以通过 Postman 来模拟一下请求了:
小红书搜索帅哥请求
我们还可以让 Postman 自动为我们生成 axios 对应的网络请求代码:
从请求的响应中,我们可以拿到笔记的标题,描述,然后进一步通过查看小红书里分享出来的笔记的可以发现其链接的格式为 https://www.xiaohongshu.com/discovery/item/${note.id}
,就可以通过这些数据初始化一个 URL 类型的消息并发送到对应的会话里。
更进一步,我们还可以维护一个关键词的池子,比如“帅哥“,”腹肌“,”少年感“等等,每次随机选择一个关键词进行搜索,并随机返回结果中的其中的一个笔记,以保证每次都能看到不一样的帅哥。这里换关键词有一个坑,光把 URL 里对应的 query 改掉是没有用的,此时请求会返回空数组。请求的 query 以及 header 里的很多字段需要同步一起改。所以每一个关键词都需要抓一次包,去获取对应的 header 和 query。
在获取到数据后,我们就可以实现相应的 command
了:
import {WechatyInterface} from 'wechaty/impls';
import * as scheduler from 'node-schedule';
export class BoyCronCommand implements CronCommand {
async run(bot: WechatyInterface): Promise<void> {
for (const config of cronConfig.boy) {
const room = await bot.Room.find({topic: config.groupName});
if (room != undefined) {
scheduler.scheduleJob(`boy-${room.id}`, config.pattern, async function() {
const link = await RedBookUtil.getBoy(bot);
if (link != undefined) {
room?.say(link);
}
});
}
}
}
}
到目前为止,我们的机器人代码已经实现得差不多了,我们还差整个程序的入口,也就是 index.js
:
import {WechatyBuilder, ScanStatus, log, Message, ContactSelf} from 'wechaty';
import {PuppetPadlocal} from 'wechaty-puppet-padlocal';
import {CronCommandController} from './lib/cron-commands/controller';
import {MessageCommandController} from './lib/message-commands/controller';
function onScan(qrcode: string, status: ScanStatus) {
if (status === ScanStatus.Waiting || status === ScanStatus.Timeout) {
log.info('bot', 'onScan: %s - %s', ScanStatus[status], qrcode);
} else {
log.info('bot', 'onScan: %s', ScanStatus[status]);
}
}
async function onLogin(user: ContactSelf) {
log.info('bot', '%s login', user);
cronCommandController.run(bot);
}
async function onMessage(message: Message) {
messageCommandController.handle(message, bot);
}
function onLogout(user: ContactSelf) {
log.info('bot', '%s logout', user);
}
const bot = WechatyBuilder.build({
name: 'my-bot',
puppet: new PuppetPadlocal({
token: "token",
}),
});
const messageCommandController = new MessageCommandController();
messageCommandController.setup();
const cronCommandController = new CronCommandController();
cronCommandController.setup();
bot
.on('scan', onScan)
.on('login', onLogin)
.on('logout', onLogout)
.on('message', onMessage)
.start()
.then(() => log.info('bot', 'bot started'))
.catch((e) => log.error('bot', e));
作为程序的入口,里面的内容其实和文章最开头的 Wechaty 差不多,因为大部分的逻辑都写在了 lib 里了。我们在初始化 bot
的同时,也会初始化 MessageCommandController
以及 CronCommandController
并调用各自的 setup
方法注册对应的命令,然后在收到消息时通过 messageCommandController
来处理消息,同时在登录完后通过 cronCommandController
启动每个定时命令。
自此我们的代码算是写完了。
但是目前机器人还是靠运行 node start
部署在我本地的。这在开发阶段是很方便的,可以快速进行调试,但是要实际上线就还远远不够,我们还要解决部署的问题。
首先是进程管理的问题,这个问题其实在开发的阶段就已经暴露出来了,当我们的代码因为各种各样的原因导致程序运行时抛了异常之后,整个进程就终止了,也就相当于机器人下线了。这时除非你手动再跑一遍 node start
重启机器人,否则是不会自动上线的。
这个问题我们可以通过 pm2 来解决:
PM2 is a daemon process manager that will help you manage and keep your application online 24/7
pm2 会在应用意外退出,也就是进程终止后自动重启应用,以保持应用一直在线。
要使用 pm2 也很简单,在通过 npm 安装后,我们只需要将 Package.json 稍作修改即可:
"scripts": {
// ...
"start": "pm2 start ecosystem.config.js",
"stop": "pm2 stop --silent ecosystem.config.js",
"dev-start": "pm2-dev --ignore-watch start ecosystem.config.js"
},
可以看到,这里的 start 脚本从原先的 node dist/index.js
改成了 pm2 start ecosystem.config.js
,同时还加了 stop
和 dev-start
两个新脚本
stop
这个很好理解,唯一要注意的是这里的 —-slient
参数,如果不加这个参数的话,应用没有启动的时候调用 stop
时候会直接报错,而 —-slient
则是让这种情况下也能正常返回。这在 CI 中是非常有用的,因为你不能保证在 stop 时应用就是在启动的状态。dev-start
也是启动应用,但是它和 start
不同点在于 start
启动后相当于命令已经运行完了,它也不会把 log 打到标准输出上,同时需要调用 stop
来停止应用;而 dev-start
则是相当于一直在运行,最终需要通过 ctrl-c
来停止。运行的问题解决了,还剩下一个部署的问题。我本想把机器人部署在公司免费的 Azure 羊毛上,不过因为羊毛的 Azure 服务器都是在国外,很容易导致机器人的微信账号被微信风控,所以最终我拿出了吃灰的旧 Mac,准备部署在这上面。
这里我选择了使用 Jenkins 来完成 CI 和部署。
Jenkins 可以直接通过 Homebrew 安装并启动:
brew install jenkins-lts
brew services start jenkins-lts
等这步完成后我们就可以在浏览器打开 http://localhost:8080,并按照指示一步步配置 Jenkins。
在继续之前,我们需要先新建一个 Jenkinsfile
:
node {
stage('Checkout') {
git url: '<https://github.com/Cokile/baymax.git>', branch: env.BRANCH_NAME
}
stage('Install Dependencies') {
sh 'npm install'
}
stage('Build') {
sh 'npm run build'
}
stage('Run') {
sh 'npm run stop'
sh 'npm start'
}
}
Jenkinsfile
描述了对应的 Jenkins Pipeline 该如何运行,这里我们分为了 4 步,首先是 checkout 对应的分支,这里的分支在实际运行时会由外部指定。然后是安装项目的依赖,接着是编译 TypeScript 代码,最后是启动应用。
当我们把我们的代码都 push 到 GitHub 上后,我们就可以在 Jenkins 里新建一个 Pipeline,在 Pipeline 的配置里可以填上对应的 repo 链接,以及其他选项:
这里我们给 Pipeline 添加了一个 BRANCH_NAME
的参数,默认值是 master
,这个值最终会传给上面 Jenkinsfile
中的 env.BRANCH_NAME
:
这里我们给 Pipeline 制定了构建的触发条件。理论上应该选 Github Hook 会好一点,这样只会在 repo 由新改动时才会触发 Pipeline 的构建,但是因为我的 Jenkins 是运行在内网上的,没办法提供一个 Webhook 的 URL,所以就选择了简单地每 30 分钟 poll 一下 repo 看看是否有新改动,因为项目本身也没多少内容,所以也不会给 Github 造成太多浪费: