又到了我们的 MXGA 时间,这次我们把目光放在了微信上。众所周知,微信是个相对封闭的平台,它不像 Telegram 那样提供了机器人的 API,供用户制作一些聊天机器人来增加一些有趣的功能,直到前段时间我从同事那儿了解到了一个叫 Wechaty 的神奇 SDK,你可以通过它实现一个微信聊天机器人。这一下子就激起我的整活欲望,必须得给我的吹水群安排上。于是便有了本文,算是对这次折腾的一个记录。

Wechaty 101

在开始之前,我们先简单介绍一下 Wechaty。Wechaty 是一个制作聊天机器人(Chatbot)的 SDK,最初是作者为了在微信上做些自动化的任务而做的 side project,后来给开源了出来,再后来随着使用的人变多,慢慢加上了对企业微信,WhatsApp,Gitter 等 IM 服务的支持,最终便成了现在的模样。

Wechaty 的架构大概如下:

图源:Architecture | Wechaty

图源:Architecture | 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 才能使用。

初始化机器人时我们传入了相应的回调去响应对应的事件:

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 模版基础上加上了 includeexclude 部分,指定了编译时需要包含哪些源码文件,整个项目的文件结构大概如下:

.
├── 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 的规则进行配置,这里就不直接放内容了,可以照着文档根据项目的需求按需进行配置。

编写代码

开发环境已经就位,接下来就可以开始愉快地写代码了。

我把它设计成了一个传统的基于命令的机器人,一个是被动的接受命令去执行操作,另一个则是定时主动执行命令。下面我们一个个来看。

MessageCommand

机器人可以接受命令去执行一些操作。我们可以把这个机器人想成是运行在微信上的命令行程序 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 命令,比如我们可以给机器人发一个 help,它会返回所有命令的描述(MessageCommand.description),发送 help xxx,则会返回 xxx 命令的使用方法(MessageCommand.helpMessage),如下图:

bot_help.png

bot_help_2.jpeg

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 取判断是返回所有命令的信息,还是只返回特定的命令的信息。

baike

不知道你在群里吹水的时候有没有遇到一种情况,其他人在聊某一个你不了解的话题/名词的时候,大部分时候你可能会问一句:“xxx 是什么”,别人可能也一时半会儿无法向你解释清楚或者无法解释,这个时候就可以让机器人来给你解释:

bot_baike.jpeg

我们再次很自然地想到可以去通过维基百科或者百度百科的 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

这个命令的灵感来源于推特,其实我们在聊天的时候也会有这样的需求,想把某个“有趣”的发言给做成图片,于是我就给机器人加了个类似功能,只需要引用某条文本消息并给机器人发个 quote 命令,机器人便会生成一张这样的图:

bot_quote.jpeg

这里我就不贴代码了,因为代码有点长,我讲一下大概的思路。

首先 Wechaty 并不支持获取 Message 引用的 Message,目前有一个 issue 开着,但还没有什么最新进展。但是我们可以曲线救国,当一条消息引用了别的消息时,Message.text 的值为:

「Cokile: xxxx」
------------------
1234

那我们就可以通过解析这个字符串得到被引用消息的发送方 Cokile 以及被引用消息的内容 xxxx,有了这两个信息之后我们就能通过 Wechaty 的 API 在群聊里找到名为 CokileContact(单聊也类似的处理逻辑,但是单聊不是自己就是对方),然后可以通过 contact.avatar().toFile() 将对应的头像下载下来,接着我们将头像和 xxxx 传入预定义好的 HTML 模版里,最终通过 puppeteer 把这个 HTML 渲染出来并生成一张截图,最后将截图发送到会话中。

MessageCommandController

有了命令后,我们自然就需要一个地方来统一解析收到的消息,获取对应的命令名称以及相关的参数并运行这个命令,所以我们又很自然地需要一个 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

机器人可以定时主动执行命令,也就是定时命令 CronCommand。可以类比 unix 下的 cron,这也正是这个命令名字的由来。CronCommand 的定义非常简单:

import {WechatyInterface} from 'wechaty/impls';

export interface CronCommand {
  run(bot: WechatyInterface): Promise<void>
}

CronCommand 只有一个 run 方法,每个命令会在这个方法里面通过 node-schedule 启动对应的定时任务,在特定的时间向回话里主动推送消息。

CronCommandController

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 方法调用一遍。

weather

定时任务一个实用的场景就是每天早上推送天气预报,方便一天的出行:

bot_weather.jpeg

实现这个功能的代码也很简单:

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 的时间格式不熟悉的话,可以通过这个网站快速上手。

boy

怎么可以忘了姐妹群看帅哥的需求呢,所以我又给机器人加上了定时推送小红书帅哥的命令:

bot_boy.jpeg

小红书本身并没有提供 API,但是我们依旧可以曲线救国,先对小红书进行抓包然后模拟小红书的请求,最后抓出来发现搜索是通过 https://edith.xiaohongshu.com/api/sns/v10/search/notes 的请求进行的。

有了抓包的数据,我们就可以通过 Postman 来模拟一下请求了:

小红书搜索帅哥请求

小红书搜索帅哥请求

我们还可以让 Postman 自动为我们生成 axios 对应的网络请求代码:

postman_boy_2.png

从请求的响应中,我们可以拿到笔记的标题,描述,然后进一步通过查看小红书里分享出来的笔记的可以发现其链接的格式为 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

到目前为止,我们的机器人代码已经实现得差不多了,我们还差整个程序的入口,也就是 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,同时还加了 stopdev-start 两个新脚本

运行的问题解决了,还剩下一个部署的问题。我本想把机器人部署在公司免费的 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 链接,以及其他选项: