DOGchan:如何做一个能够自动回复的 Mastodon bot

任何时间,任何地点,可爱狗狗,认真汪汪!

缪尚咖啡馆已经有只傲娇猫猫,当然也要有可爱狗狗!于是决定学习制作一个能够自动回复的狗狗 bot,让缪尚咖啡馆实现猫狗双全。让我们有请小狗汪汪!

利用 Node 及 mastodon-api 实现自动回复 bot

由于这一方法在尝试过程中 bug 百出,且没有找到合适的解决方案,暂时搁置,转而使用 Mastodon.py
但此方法本身较为好懂,油管教程也也非常详细、容易上手。虽然很不想承认,但我没有搭建成功应该是我的问题,可能会在以后更熟悉 JavaScript 时再回头看这个教程。

参考

长毛象顶流:操操
油管教程:Mastodon API and Bots: Node.js

注册账号

选择一个合适的站点注册 bot 账号,并修改资料,注意选中 “这是一个 bot 账号 / This is a bot account”。
在管理面板找到开发/Development-创建新应用/New Application,创建新应用并给予读写权限,复制访问令牌。

安装 node

由于服务器上还没有安装 npm,所以第一步需要先安装。直接使用命令安装。

1
2
3
apt install npm
mkdir dogchan # 为 bot 相关文件新建目录
cd dogchan

接下来运行命令:

1
npm init

根据提示一步步填写初始化信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (dogchan) dogchan
version: (1.0.0) 0.0.1
description: A dog bot
entry point: (index.js) bot.js
test command:
git repository:
keywords: bot, mastodon
author: melocery
license: (ISC) MIT
About to write to /root/miniconda/dogchan/package.json:

{
"name": "dogchan",
"version": "0.0.1",
"description": "A dog bot",
"main": "bot.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"bot",
"mastodon"
],
"author": "melocery",
"license": "MIT"
}


Is this OK? (yes) yes

现在查看 dogchan 目录下会多出一个名为 package.json 的文件,内容是刚才初始化填写的信息。

配置环境

运行命令:

1
npm install dotenv

出现问题:

1
2
npm WARN notsup Unsupported engine for dotenv@16.0.1: wanted: {"node":">=12"} (current: {"node":"10.19.0","npm":"6.14.4"})
npm WARN notsup Not compatible with your version of node/npm: dotenv@16.0.1

提醒安装的 nodenpm 版本不足。查找教程升级版本后,再次运行命令出现新的报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
node:internal/modules/cjs/loader:936
throw err;
^

Error: Cannot find module 'semver'
Require stack:
- /usr/share/npm/lib/utils/unsupported.js
- /usr/share/npm/bin/npm-cli.js
at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
at Function.Module._load (node:internal/modules/cjs/loader:778:27)
at Module.require (node:internal/modules/cjs/loader:1005:19)
at require (node:internal/modules/cjs/helpers:102:18)
at Object.<anonymous> (/usr/share/npm/lib/utils/unsupported.js:2:14)
at Module._compile (node:internal/modules/cjs/loader:1105:14)
at Object.Module._extensions..js (node:internal/modules/cjs/loader:1159:10)
at Module.load (node:internal/modules/cjs/loader:981:32)
at Function.Module._load (node:internal/modules/cjs/loader:822:12)
at Module.require (node:internal/modules/cjs/loader:1005:19) {
code: 'MODULE_NOT_FOUND',
requireStack: [
'/usr/share/npm/lib/utils/unsupported.js',
'/usr/share/npm/bin/npm-cli.js'
]
}

谷歌解决方法大多数是完全卸载后重装,但无论卸载重装多少次,总会遇到 Error: Cannot find module 'semver'。最后的解决方式是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 完全 uninstall node 和 npm
sudo rm -rf /usr/local/bin/npm /usr/local/share/man/man1/node* ~/.npm
sudo rm -rf /usr/local/lib/node*
sudo rm -rf /usr/local/bin/node*
sudo rm -rf /usr/local/include/node*

sudo apt-get purge nodejs npm
sudo apt autoremove

# reinstall
wget https://nodejs.org/dist/v16.16.0/node-v16.16.0-linux-x64.tar.xz
tar -xf node-v16.16.0-linux-x64.tar.xz
sudo mv node-v16.16.0-linux-x64/bin/* /usr/local/bin/
sudo mv node-v16.16.0-linux-x64/lib/node_modules/ /usr/local/lib/

# 检查安装情况
node -v
npm -v

这里再次出现状况,运行 npm -v 时报错:

1
-bash: /usr/bin/npm: No such file or directory

但如果换为运行 /usr/local/bin/npm -v 则能够正常显示版本号。没有再次搜索问题原因,直接在 .bashrc 文件中增加一行:

1
alias npm='/usr/local/bin/npm'

而后执行 source .bashrc。此时运行 npm -v 即可正常显示版本号。

重新进行关于 npm 的所有命令,直到安装完成 dotenv。安装完成后出现提醒信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
added 1 package, and audited 236 packages in 1s

5 packages are looking for funding
run `npm fund` for details

8 vulnerabilities (2 moderate, 3 high, 3 critical)

To address issues that do not require attention, run:
npm audit fix

Some issues need review, and may require choosing
a different dependency.

Run `npm audit` for details.

没有找到解决方法,运行 npm audit fix --force 也无法解决 8 vulnerabilities,暂时搁置。

新建文件 .env,将前述步骤中得到的 bot 访问令牌填入文件:

1
2
3
4
5
6
7
8
# 应用 ID
CLIENT_KEY=
# 应用密钥
CLIENT_SECRET=
# 访问令牌
ACCESS_TOKEN=
# bot 所在站点
API_URL=https://musain.cafe/api/v1/

保存并退出。
新建 bot.js 文件,在文件内写入:

1
require('dotenv').config();

由于后续会将整个 bot 的代码上传至 github,所以需要将真正的 .env 隐藏起来而上传一个示例文件。

1
2
3
4
5
6
7
8
9
10
11
nano .env-example

## 写入如下内容后保存并退出,文件中不需要 '#',此处该符号仅为标记文件内容
# CLIENT_KEY=
# CLIENT_SECRET=
# ACCESS_TOKEN=
# API_URL=https://musain.cafe/api/v1/

nano .gitignore
## 写入如下内容后保存并退出,文件中不需要 '#',此处该符号仅为标记文件内容
# .env

connect to mastodon

与 mastodon 连接需要使用 node package (mastodon-api),安装命令:

1
npm install --save mastodon-api

安装完成后目录下会多出一个目录 node_modules 和一个文件 package-lock.json。查看 package.json 会发现其中多出一段:

1
2
3
"dependencies":{
"mastodon-api": "^1.3.0"
}

想要连接 mastodon 则需要编辑 bot.js 文件,在文件内写入:

1
2
3
4
5
6
7
8
9
const mastodon = require('mastodon-api');

const M = new Mastodon({
client_key: process.env.CLIENT_KEY, // read the .env file
client_secret: process.env.CLIENT_SECRET,
access_token: process.env.ACCESS_TOKEN,
timeout_ms: 60 * 1000, // optional HTTP request timeout to apply to all requests.
api_url: process.env.API_URL, // optional, defaults to https://mastodon.social/api/v1/
})

log file

每一次发送嘟文会在屏幕上打印一大串内容,包括嘟文发送情况、嘟文的链接等等,但并不需要每一次都打印详细信息,只需要将 debug 所需的信息输出到 log 文件即可。
bot.js 文件中写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const fs = require('fs');

// toot example
const num = Math.floor(Math.randon()*100);
const params = {
status: `The meaning of life is ${num}`
}

// log
M.post('statuses', params, (error, data) => {
if (error) {
console.error(error);
} else {
// fs.writeFileSync('data.json', JSON.stringify(data, null, 2));
// console.log(data);
console.log(`ID: ${data.id} and timestamp: ${data.created_at}`); // get the id and time
console.log(`Posted: ${data.content}`); // get the content of toot
}
});

上述代码实际实现了一次完整的发送嘟文,因此可以封装为一个函数,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const fs = require('fs');
// toot function: pick a random num, create a status, and toot it
function toot(){
// toot example
const num = Math.floor(Math.randon()*100);
const params = {
status: `The meaning of life is ${num}`
// if want to use cw
// spoiler_text: " The meaning of life is",
// status: num
}

// log
M.post('statuses', params, (error, data) => {
if (error) {
console.error(error);
} else {
// fs.writeFileSync('data.json', JSON.stringify(data, null, 2));
// console.log(data);
console.log(`ID: ${data.id} and timestamp: ${data.created_at}`); // get the id and time
console.log(`Posted: ${data.content}`); // get the content of toot
}
});
}

streaming api: user

目前 bot 已经实现自动发嘟,但如果想做一个可以自动回复、给特定人点星而不是随机骚扰用户,则需要监听 streaming api 中的用户部分,并根据所得信息进行相应回应。
bot.js 文件中写入:

1
2
3
4
5
6
const listener = M.stream('streaming/user')

listener.on('message', msg => {
fs.writeFileSync(`data${new Date().getTime()}.json`, JSON.stringify(msg, null, 2));
console.log(`usr event`);
});

通过 node bot.js 运行 bot,并利用其它账号 @bot 或关注等操作测试 bot 是否正确运行。这一步遇到问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Mastodon Bot starting...
node:events:505
throw er; // Unhandled 'error' event
^

Error: aborted
at connResetException (node:internal/errors:692:14)
at TLSSocket.socketCloseListener (node:_http_client:414:19)
at TLSSocket.emit (node:events:539:35)
at node:net:709:12
at TCP.done (node:_tls_wrap:582:7)
Emitted 'error' event on StreamingAPIConnection instance at:
at IncomingMessage.<anonymous> (/root/miniconda/dogchan/node_modules/mastodon-api/lib/streaming-api-connection.js:141:30)
at IncomingMessage.emit (node:events:539:35)
at emitErrorNT (node:internal/streams/destroy:157:8)
at emitErrorCloseNT (node:internal/streams/destroy:122:3)
at processTicksAndRejections (node:internal/process/task_queues:83:21) {
code: 'ECONNRESET'
}

经过一整天的搜索和试图 debug 决定放弃,转而使用 python 中的 Mastodon.py,即 CATsama 使用的包。

利用 Mastodon.py 实现自动回复 bot

参考

Mastodon.py: Docs , GitHub
mastodon-bot-template: reply.py
mstdn-ebooks: reply.py

注册账号

选择一个合适的站点注册 bot 账号,并修改资料,注意选中 “这是一个 bot 账号 / This is a bot account”。
在管理面板找到开发/Development-创建新应用/New Application,创建新应用并给予读写权限,复制访问令牌。

安装 Mastodon.py

安装 miniconda3:

1
2
3
4
5
6
mkdir miniconda
cd miniconda

# 下载 python 最新版本并用 bash 安装
wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh
sh Miniconda3-latest-Linux-x86_64.sh //用bash安装

通过 pip 安装 Mastodon.py:

1
2
3
4
5
pip3 install requests beautifulsoup4 Mastodon.py # 安装mastodon.py
mkdir dogchan
cd dogchan
nano mybot_usercred.secret # 写入刚才复制的访问令牌,保存退出
nano dogchan.py # 发嘟脚本

编辑脚本

整个过程基本上属于一边参考现有的利用 Mastodon.py 实现自动回复的库,一边自己悟每一步都是做什么的。
其中最实用的一个库是: mastodon-bot-template。这个库不仅包括了自动回复模板,也包括了自动发嘟模板。本项目中的 extract_text 函数、process_mention 函数和处理 notifications 都直接借用模板或稍加修改。模板作者要求使用该模板时引用原库,且使用同样的 license (AGPL-3.0 license)。

自动回复脚本

自动回复脚本需要完成接收到 notification、判定 notification 是 mention 、对 mention 嘟文内容进行处理、作出相应的回复。其中所需的函数较多,因此将大多数函数存在 Woof.py 中,大框架放在 dogchan.py 中。
代码基本参考前面提到的 mastodon-bot-template: reply.py。其中代码的注释非常详细,此处不再重复。

完成脚本后使用 systemd 设置定时任务,让服务器自动每 30 秒执行一次脚本。这里的自动执行不如前面尝试失败的方法 利用 mastodon-api 实现自动回复 bot 优雅,如果能够进一步提升将在此处指出。
在服务器上安装 systemd:

1
sudo apt-get install systemd

任务设置模板 (来自于 mastodon-bot-template):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# an example for systemd

[Unit]
Description=Reply service for my bot
After=network.target

[Service]
Type=simple
User=bots
WorkingDirectory=/path/to/bot
ExecStart=/usr/bin/python3 /path/to/bot/reply.py
TimeoutSec=3600
Restart=always
RestartSec=30
StartLimitBurst=3

[Install]
WantedBy=multi-user.target

自动发嘟脚本

自动发嘟脚本 selfie.py 主要用于实现每周三上午九点发一张汪汪照片。相关代码可参考 一个简单的 mastodon bot 或前面提到的 mastodon-bot-template
若想要在嘟嘟中添加图片,则需要调用模板以外的函数,具体如下:

1
2
3
4
5
6
7
8
9
# 随机选取指定目录中的一个文件,path2selfie.txt 中含有该目录下所有文件的绝对路径
media_file = random.choice(open("path2selfie.txt").read().splitlines())
# 处理图片路径,使其成为可用于 mastodon.status_post 的参数
selfie = mastodon.media_post(media_file)
# 带上图片发嘟
mastodon.status_post(
status = content,
media_ids = selfie,
)

定时每周三上午九点发送一张带有照片的嘟嘟:

1
0 9 * * 3  cd /root/miniconda/dogchan && /root/miniconda3/envs/mastbot/bin/python selfie.py >> /root/miniconda/dogchan/log/selfie_log.txt 2>&1

奇怪的报错

谷歌一圈,也在 Mastodon.py 提了 issue,还是没有解决。推测这个 bug 是我的站点设置造成的,但完全没有头绪究竟是哪里有问题,用 mstdn.social 的账号测试就没有问题。回看尝试 Node.js 放弃时的报错,很有可能也是同样的问题导致 API 报错,但已经懒得验证。

2022.08.08 更新

提的 issue 得到回复,确定是站点设置的问题。但作者不清楚是哪里出现问题,给出的建议是每当出现该报错就重建一次 streaming,但有可能丢失部分 notifications。
还有一个解决办法是每十分钟从上次读到的 notifications 并进行操作以实现自动回复。这种方法其实是 “伪自动回复”,因此需要设置定时任务,如每十分钟执行一次脚本。目前 DOGchan 使用这种方法实现自动回复。

将代码上传至 GitHub

之前的 CATsama 已经上传过一次,但这次上传还是完全不记得怎么搞,所以决定记录一下。

创建仓库

在 GitHub 创建新的仓库以存放代码。根据实际需要,为仓库添加 README.mdlicense。创建完成后,在仓库右上角点击绿色的 Code,复制相应链接。

密钥

可直接参考 生成新 SSH 密钥并添加到 ssh-agent

Personal access tokens

在 GitHub 创建 token 以便于后续代码上传时身份验证。点击个人头像 - Setting - Developer settings - Personal access tokens - Generate new token,根据实际情况选择 token 的执行权限,完成后复制 token。
将创建的 Personal access tokens 整合到前述步骤复制的仓库链接中备用,具体格式如下:

1
https://Personal_access_tokens@github.com/your_username/repository_name.git

上传

在服务器上存放代码的目录下执行如下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# GitHub 默认分支已经改为 main,但 git init 的默认分支还是 master,这一步用于将 git 的默认分支改为 main
git config --global init.defaultBranch main

# 初始化库
git init

# 添加当前目录下所有文件,也可添加指定文件
git add .

# commit
git commit -m "first commit"

# 添加远程仓库并命名为 origin,粘贴前述步骤中整合后的仓库链接
git remote add origin https://Personal_access_tokens@github.com/your_username/repository_name.git

# 如果提示远程仓库已存在,则执行如下命令后再添加一次
# git remote rm origin

# 拉取远程仓库中的内容,否则 push 时也会因为未同步远程仓库内容而失败
# 如果远程仓库为空,则不需要这一步
git pull origin main --allow-unrelated-histories

# 将 commit 推送到远程仓库
git push origin main

此时刷新 GitHub 库的页面,即可看到新上传的内容。
DOGchan 的 GitHub 库:DOGchan:一个能够关键字触发自动回复的长毛象机器人

后记

整个搭建过程非常漫长,先是觉得油管教程很详细不可能有任何问题,但随着一步步搭建发现问题不少,而且由于对 node.js 完全不熟悉,连报错都看不懂,只能粘贴到谷歌搜索,但也不能得到很好的解决。又问了很多在象上拥有自动回复 bot 的象友,想参考一下,但大家的方法也不能完全适用。最后还是在 GitHub 一个个项目看过去 (感谢愿意开源的人们),找到了现在参考的这个方法。虽然也有不足,但是是目前确实能顺利运行一段时间的方法。接下来只需要思考为什么我的站点 streaming 不能长时间访问,修改好就能顺利运行。
总得来说,也算是一次很快乐的尝试。做这些小小的 project 比科研有趣多了!或许以后还会做这些有趣的小项目,快乐的同时还能丰富 GitHub 的库。

一开始这个 bot 只是为了让缪尚猫狗双全,但做着做着就想起家里的狗狗。在真实世界它已经返回汪星,但它在我自己搭建的赛博世界玩耍。因此从发嘟的照片和头像都用了狗狗的照片,就让它在我的咖啡馆继续做一只快乐小狗吧!