生活中学校通知信息都会借助微信群聊发布,并且时常会要求“收到请回复”。这就导致了少数重要信息与大量无关信息混杂在一起,屏蔽与不屏蔽都不是好办法。遂实现了命令行控制的「微信机器人」,并在此基础上完成了群聊信息过滤功能。
1. 开发过程
- 微信官方并没有相关的API。已知的
企业微信机器人
和聊天平台
有局限性,并不能灵活的完成我所期望的功能以及实现未来可能的拓展。 - 找了很多开源的
wxbot
,但他们都是基于Web版微信,笔者的微信账号并不能登陆成功。 - 后来找到了
Wechaty
,发现它完美解决了与微信交互的问题,并且封装了各式各样的接口,有详细的API文档。于是参与了开源激励计划,获取了免费甚至长期有效的iPad Puppet的Token。
2. 核心代码
完整代码请访问LazyBot Public Repo。
利用Wechaty与微信交互
查阅了Wechaty
的API文档,并且学习了介绍视频
之后,笔者首先完成了一个入口程序,以方便移植与拓展。
// ./index.js
import tokenJSON from './token.json'
import infoJSON from './package.json'
console.log(`Running LazyBot ${infoJSON.version}...`)
console.log(`Trying to detect 'token' from './token.json'`)
if (!tokenJSON.token) {
console.log(`Unable to acquire 'token' field from './token.json'. If you don't possess token of Wechaty, turn to https://github.com/juzibot/Welcome/wiki/Everything-about-Wechaty for more information.`)
return
}
console.log(`Detect 'token' from './token.json': ${tokenJSON.token}`)
import main from './main.js'
main(tokenJSON.token, './bot-settings.json')
其中token
被保存在./token.json
中,bot
的设定被保存在./bot-settings.json
中。
随后,笔者仿照视频
搭建了一个Wechaty
的基本框架。
// ./main.js
module.exports = function(token, botSettingFile) {
// Import Wechaty
// ...
// Import Settings
// ...
import botSettings from './bot-settings.json';
import schedule from 'node-schedule';
// Import Command System
import commandUnits from './commands.js';
import utils from './utils.js';
// Construct Puppet
const puppet = new PuppetPadplus({ token });
const name = "LazyBot";
const bot = new Wechaty({ puppet, name });
// Initialize Writing Settings
// Writing Settings of Bot to local file every 10 min.
// ...
// Initialize Bot Settings
// ...
// Begin Bot
bot
.on('scan', function (qrcode, status) {
if (status === ScanStatus.Waiting) {
QrcodeTerminal.generate(qrcode);
}
})
.on('login', function (user) {
console.log(`${user.name()} Login`);
})
.on('logout', function (user) {
console.log(`${user.name()} Logout`);
})
.on('room-invite', async roomInvitation => {
// ...
})
.on('room-leave', async (room, leaverList, remover) => {
// ...
})
.on('friendship', async (friendship) => {
// ...
})
.on('message', async function(message){
// ...
});
bot.start();
}
其中,bot
的配置被保存在了botSettings
当中,并且每隔10 min就被保存到本地./bot-settings.json
中。
解析命令
首先,为了区别普通的消息与命令,笔者规定任何以.
开头的消息文本都被视为命令。但是保险起见,笔者要求在群聊当中,
需要先开启 LazyBot
,然后才会触发解析。
// ./utils.js
/**
* Test whether message is a command
* @param {string} message
* @returns {boolean}
*/
function isCommand(message) {
return message[0] === '.';
}
// ./main.js -> 'message' event function
if (message.room()) {
const id = message.room().id;
// ...
// Initialize settings of group
if (!botSettings["groups"][id]) botSettings["groups"][id] = { "switch": false, "monitors": {} };
// ...
if (utils.isCommand(text)) {
// Enable or Disable LazyBot
if (text === ".enable-lazybot") { botSettings["groups"][id]["switch"] = true; return;}
else if (text === ".disable-lazybot") { botSettings["groups"][id]["switch"] = false;return; }
// ... Parse Command if ("switch" === true) and Do Something
}
}
然后,是解析的实现。命令以空格
作为分隔符,但是考虑到有时候空格可能会作为参数
的一部分,笔者采用了被包裹在配对的"
或'
之间的空格
将不再被视作为分隔符的解决方案。解析完毕后,会生成一个命令对象。
// ./utils.js
/**
* Parse message as command
* ' ', which is not between '' or "", is perceived as separator
* subCommand beginning with '-' is perceived as non-boolean flag
* subCommand beginning with '--' is perceived as boolean flag
* @param {string} message
*/
function parseCommand (message) {
// ... function for checking boolean flag
// ... function for checking non-boolean flag
const ret = {
mainCommand:"",
flags:{},
args:[],
err: ""
};
const _commands = message.split(' ');
const commands = [];
// After Splitting message by ' ', merge items like ["a, b"], and delete quotes.
// e.g. ".abc 'abc e'" => [".abc", "'abc", "e'"] => [".abc", "abc e"]
// ...
// Parse Commands
// ...
return ret;
}
其中,以--
开头的参数被认为是boolean flag
,而以-
开头的参数被认为是non-boolean flag
,并且其后所接的参数被认为属于这个flag
。
至此,命令行的输入与解析就实现完成。之后就是相应功能的实现。为了能够一般化命令行指令,笔者定义了一个处理中心,接收解析好的命令,并且 找到合适的实例去执行。
// ./commands.js
/**
* Command ".help" is reserved for listing all possible commands with their descriptions.
*/
class CommandUnits{
RegisterCommand(command, description) {
this.commands[command.mainCommand] = command;
this.descriptions[command.mainCommand] = description;
}
RegisterRegexCommand(regex, command, display, description) {
this.regexCommands.push({
regex, command, display, description
});
}
CallCommand(parsedCommands, message, botSettings){
if (parsedCommands.mainCommand === ".help") {
// Display All Possible Commands
// ...
return ret;
}
// Test for complete Match first
if (this.commands[parsedCommands.mainCommand]) return this.commands[parsedCommands.mainCommand].Call(parsedCommands, message, botSettings);
for (const regexCommand of this.regexCommands) {
if (regexCommand.regex.test(parsedCommands.mainCommand)) return regexCommand.command.Call(parsedCommands, message, botSettings);
}
return `Unrecognized Command: ${parsedCommands.mainCommand}.`;
}
Ready(){
// Sort Regex Command to Display more beautifully
// ...
}
constructor() {
this.commands = {};
this.descriptions = {};
this.regexCommands = [];
}
}
其中,CommandUnits
接收正常的Command
和正则形式的Regex Command
,所有的Command
在使用前都需要显式的注册到CommandUnits
中。
并且,CommandUnits
在确定接受完所有的注册后,需要显式的调用Ready
去做一些初始化工作。
随后,就是Command
的具体实现。同样,笔者定义了一个类来完成封装。
// ./commands.js
class Command{
_parse(commands) {
const parsedCommands = {
mainCommand: commands.mainCommand,
err: "",
args: [],
flags:{}
};
// Parse parsed Commands to suit needs of this specific instance
// ...
return parsedCommands;
}
Call(commands, message, botSettings) {
const parsedCommands = this._parse(commands);
if (parsedCommands.err !== "") return parsedCommands.err;
if (parsedCommands.flags["help"] || parsedCommands.flags["h"] !== undefined) return this.help();
return this.caller(parsedCommands, message , botSettings);
}
help() {
// Return Help Information for this instance
// ...
}
/**
*
* @param {string} mainCommand
* @param {Array<{flag: string, description: string}>} booleanFlags
* @param {Array<{flag: string, description: string}>} nonBooleanFlags
* @param {(commands, message, botSettings) => string} caller
*/
constructor(mainCommand, booleanFlags, nonBooleanFlags, caller) {
this.mainCommand = mainCommand;
this.booleanFlags = { flags: [], descriptions:{}};
this.nonBooleanFlags = { flags: [], descriptions:{} };
for (const flag of booleanFlags) {
this.booleanFlags.flags.push(flag["flag"]);
this.booleanFlags.descriptions[flag["flag"]] = flag["description"] || "";
}
// Add `--help` command
// ...
for (const flag of nonBooleanFlags) {
this.nonBooleanFlags.flags.push(flag["flag"]);
this.nonBooleanFlags.descriptions[flag["flag"]] = flag["description"] || "";
}
// Add `-h` command
// ...
this.caller = caller;
}
}
其中,flags
被以Array<Object>
的形式注册到Command
中,相应的处理函数也被注册到其中。笔者设计Command
类时,要求为Command
和每个
flag
提供帮助文本以自动生成帮助信息
。
下面是两个简单的使用的例子。
// 让 Bot 识别 .hi 指令,对注册过的用户回复“欢迎”的消息。
commandUnits.RegisterCommand(new Command(".hi",[],[],
function (commands, message, botSettings) {
if (!botSettings["users"][message.from().id]) {
console.log(`Invalid .hi command from unregistered user ${message.from().id}`);
return registerPrompt;
}
return `Hello, ${message.from().name()}! What a nice day!`;
}), "Say Hi to Bot");
// 让 Bot 识别 .register 指令, 以接受用户的注册。对注册过的用户,提示已经注册过,除非显式加入`--force`参数。
commandUnits.RegisterCommand(new Command(".register",
[
{
flag:"force",
description:"Force to register account. In this case, the original account will be deleted, if have registered."
}
], [],
function (commands, message, botSettings) {
if (message.room()) return "";
const id = message.from().id;
if (botSettings["users"][id] && !commands.flags["force"]) {
console.log("Account",id,"Tried to register again");
return `Have registered Account ${id}`;
}
botSettings["users"][id] = {};
console.log("Account Registered",id);
return `Hello, ${message.from().name()}!`;
}), "Register Account");
实现群聊信息过滤
在准备好所有工具过后,就要实现群聊信息的过滤了。笔者采用的是黑名单
和白名单
方案,各个方案接受指定用户
的检测或者是基于正则表达式
的文本检测。
保险起见,笔者要求用户显式的开启过滤功能,并且为黑名单
和白名单
分别设置了开关。
// ./command.js
commandUnits.RegisterCommand(new Command(".monitor",
[
{flag: "off", description: "Turn Off The Monitor"},
{flag: "whitelist", description: "Toggle the WhiteList"},
{flag: "blacklist", description: "Toggle the BlackList"}
],
[],
async function (commands, message, botSettings) {
// Check whether `.monitor` is valid in current environment
// ...
const id = message.room().id;
const userId = message.from().id;
if (!botSettings["groups"][id]["monitors"][userId]) {
// Initialize User Settings in Monitors
// ...
}
// Parse Options
// ...
return "";
}), "Monitor Group Chat");
commandUnits.RegisterCommand(new Command(".whitelist",
[
{flag:"delete", description: "Whether to delete rules instead of adding"},
{flag:"show", description: "Display WhiteList"},
],
[
{flag:"u", description: "Add/Delete User to/from White List"},
{flag:"m", description: "Add/Delete Message Filter (Regex) to/from White List"}
],
async function (commands, message, botSettings) {
// Check whether `.whitelist` is valid in current environment
// ...
const topic = await message.room().topic();
const id = message.room().id;
const userId = message.from().id;
if (commands.flags["show"]) {
// Send Setting Information of White List to User
// ...
return "";
}
if (commands.flags["u"]) {
// Get User's Id from its name and Add / Delete it to / from settings if appropriate.
// ...
return "";
}
if (commands.flags["m"]) {
// Add / Delete filtering rule to / from settings if appropriate.
// ...
return "";
}
}), "Manipulate WhiteList of Message Filter of Group Chat");
其中,.blacklist
同理。
然后,完善main.js
中的相应事件,LazyBot
就初步搭建成功。
示例
3. 已知问题
- 暂时无法完成连续性指令。
- 设置被缓存在本地文件当中,写入数据库会更好。
4. 优势
- 实现了泛式的命令行控制,易于拓展功能。