瞎写的 Mastodon 迎新机器人
air@acg.mn最近被劝退的新人有点多,还是写个迎新机器人好了……
懒人决定简单粗暴 PY 一把梭!
注册帐号
Mastodon 的 Bot 帐号就是普通的用户帐号,由于管理员也不能在后台手工添加新用户,你需要使用新的邮箱地址重新注册一个帐号并登入(可以使用浏览器的无痕标签)。所幸,不少邮件服务商(比如 Gmail, iCloud, Outlook, Protonmail)支持使用 + 地址来接收邮件(比如 air+acgmn@gmail.com 会自动发往 air@gmail.com ),详见:
https://acg.mn/@air/101985791896015883
接下来就是如何登入帐号啦,根据 文档,目前 Mastodon 支持 OAuth 2 的三种登录方式:传统的帐号密码登录(Password grant flow)、授权码登录(Authorization code flow,目前绝大部分客户端均采用这种方式登录,需要打开浏览器授权获取验证码再跳转回应用)以及简单粗暴的 access_token
登录(Client credentials flow)。考虑到这次的 Bot 打算瞎写,就用 access_token
登录好了。
为了方便起见,直接前往帐户设置里的开发选项申请一个新应用:
https://acg.mn/settings/applications/new
申请完成后点进去就能看到 access token 了(中文翻译是"访问令牌"),不妨试一下,打开 PY(文档):
>>> import requests >>> from pprint import pprint >>> token = 'xxxxxxxxx' >>> headers = {'Authorization': 'Bearer ' + token} >>> pprint(requests.get('https://acg.mn/api/v1/accounts/verify_credentials', headers=headers).json()) {'acct': 'new', 'avatar': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/059/612/original/2662913a01443e26.png', 'avatar_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/059/612/original/2662913a01443e26.png', 'bot': True, 'created_at': '2019-04-28T00:58:53.863Z', 'display_name': 'ACG.MN 迎新机器人', 'emojis': [], 'fields': [], 'followers_count': 0, 'following_count': 2, 'header': 'https://acg.mn/headers/original/missing.png', 'header_static': 'https://acg.mn/headers/original/missing.png', 'id': '59612', 'locked': False, 'note': '<p>欢迎加入 ACG.MN!</p><p>维护者: <span class="h-card"><a ' 'href="https://acg.mn/@air" class="u-url ' 'mention">@<span>air</span></a></span></p>', 'source': {'fields': [], 'language': None, 'note': '欢迎加入 ACG.MN!\r\n\r\n维护者: @air@acg.mn', 'privacy': 'public', 'sensitive': False}, 'statuses_count': 0, 'url': 'https://acg.mn/@new', 'username': 'new'}
Well done! 🍺
PS: 可能会有人有疑问,access token
不需要更换的吗?
Mastodon 使用的 Doorkeeper 默认是两小时一换,然而到目前为止(v2.8.0),Mastodon 的所有 access token
默认都是永不过期,参见:
https://github.com/tootsuite/mastodon/blob/master/config/initializers/doorkeeper.rb#L25
嘛,反正省事了也挺好……另外也许有人好奇 API rate limit,印象里好像是 5 分钟 300 次,删除嘟文的话 30 分钟 30 条的样子,参见:
https://github.com/tootsuite/mastodon/blob/master/config/initializers/rack_attack.rb#L48
探索 API
接下来尝试发个 Hello World 吧(文档):
>>> status = ''' ... Hello World! ... 你好,世界! ... ... 管理员 @air@acg.mn ... 话题 #test ... ''' >>> pprint(requests.post('https://acg.mn/api/v1/statuses', headers=headers, data={'status': status, 'visibility': 'unlisted'}).json()) {'account': {'acct': 'new', 'avatar': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/059/612/original/2662913a01443e26.png', 'avatar_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/059/612/original/2662913a01443e26.png', 'bot': True, 'created_at': '2019-04-28T00:58:53.863Z', 'display_name': 'ACG.MN 迎新机器人', 'emojis': [], 'fields': [], 'followers_count': 0, 'following_count': 2, 'header': 'https://acg.mn/headers/original/missing.png', 'header_static': 'https://acg.mn/headers/original/missing.png', 'id': '59612', 'locked': False, 'note': '<p>欢迎加入 ACG.MN!</p><p>维护者: <span class="h-card"><a ' 'href="https://acg.mn/@air" class="u-url ' 'mention">@<span>air</span></a></span></p>', 'statuses_count': 2, 'url': 'https://acg.mn/@new', 'username': 'new'}, 'application': {'name': 'ACG.MN 迎新机器人', 'website': ''}, 'card': None, 'content': '<p>Hello World!<br />你好,世界!</p><p>管理员 <span class="h-card"><a ' 'href="https://acg.mn/@air" class="u-url ' 'mention">@<span>air</span></a></span><br />话题 <a ' 'href="https://acg.mn/tags/test" class="mention hashtag" ' 'rel="tag">#<span>test</span></a></p>', 'created_at': '2019-04-28T02:56:27.975Z', 'emojis': [], 'favourited': False, 'favourites_count': 0, 'id': '102001553438266338', 'in_reply_to_account_id': None, 'in_reply_to_id': None, 'language': 'en', 'media_attachments': [], 'mentions': [{'acct': 'air', 'id': '48702', 'url': 'https://acg.mn/@air', 'username': 'air'}], 'muted': False, 'pinned': False, 'poll': None, 'reblog': None, 'reblogged': False, 'reblogs_count': 0, 'replies_count': 0, 'sensitive': False, 'spoiler_text': '', 'tags': [{'name': 'test', 'url': 'https://acg.mn/tags/test'}], 'uri': 'https://acg.mn/users/new/statuses/102001553438266338', 'url': 'https://acg.mn/@new/102001553438266338', 'visibility': 'unlisted'}
注意:尽管 Mastodon 支持简单的 HTML 标签,但 API 里发表和读取 status 均只能使用纯文本。如果遇到命令执行错误会自动返回执行的命令作为错误信息,可能会触发两个 bot 不停互相调用的死循环……所以处理文本信息记得先把所有 @ 标签删除再处理!
嗯,尝试一下读取通知?(文档)
>>> pprint(requests.get('https://acg.mn/api/v1/notifications', headers=headers).json()) [{'account': {'acct': 'air', 'avatar': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/048/702/original/80fe78f74efcf8f1.png', 'avatar_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/048/702/original/80fe78f74efcf8f1.png', 'bot': False, 'created_at': '2019-01-16T11:53:11.247Z', 'display_name': 'Seaside', 'emojis': [], 'fields': [], 'followers_count': 61, 'following_count': 51, 'header': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/headers/000/048/702/original/739f6c295841b15d.jpg', 'header_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/headers/000/048/702/original/739f6c295841b15d.jpg', 'id': '48702', 'locked': False, 'note': '<p>KY/キモイ/陰キャ/クズ/玻璃心/NPC Energy</p>', 'statuses_count': 688, 'url': 'https://acg.mn/@air', 'username': 'air'}, 'created_at': '2019-04-28T03:00:20.851Z', 'id': '11702', 'status': {'account': {'acct': 'air', 'avatar': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/048/702/original/80fe78f74efcf8f1.png', 'avatar_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/048/702/original/80fe78f74efcf8f1.png', 'bot': False, 'created_at': '2019-01-16T11:53:11.247Z', 'display_name': 'Seaside', 'emojis': [], 'fields': [], 'followers_count': 61, 'following_count': 51, 'header': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/headers/000/048/702/original/739f6c295841b15d.jpg', 'header_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/headers/000/048/702/original/739f6c295841b15d.jpg', 'id': '48702', 'locked': False, 'note': '<p>KY/キモイ/陰キャ/クズ/玻璃心/NPC Energy</p>', 'statuses_count': 688, 'url': 'https://acg.mn/@air', 'username': 'air'}, 'application': None, 'card': None, 'content': '<p><span class="h-card"><a href="https://acg.mn/@new" ' 'class="u-url mention">@<span>new</span></a></span> ' 'test reply :aqua:</p>', 'created_at': '2019-04-28T03:00:20.070Z', 'emojis': [{'shortcode': 'aqua', 'static_url': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/custom_emojis/images/000/011/939/static/16097b10a38854df.png', 'url': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/custom_emojis/images/000/011/939/original/16097b10a38854df.png', 'visible_in_picker': True}], 'favourited': False, 'favourites_count': 0, 'id': '102001568649077630', 'in_reply_to_account_id': '59612', 'in_reply_to_id': '102001553438266338', 'language': 'zh', 'media_attachments': [], 'mentions': [{'acct': 'new', 'id': '59612', 'url': 'https://acg.mn/@new', 'username': 'new'}], 'muted': False, 'poll': None, 'reblog': None, 'reblogged': False, 'reblogs_count': 0, 'replies_count': 0, 'sensitive': False, 'spoiler_text': '', 'tags': [], 'uri': 'https://acg.mn/users/air/statuses/102001568649077630', 'url': 'https://acg.mn/@air/102001568649077630', 'visibility': 'unlisted'}, 'type': 'mention'}, {'account': {'acct': 'air', 'avatar': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/048/702/original/80fe78f74efcf8f1.png', 'avatar_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/048/702/original/80fe78f74efcf8f1.png', 'bot': False, 'created_at': '2019-01-16T11:53:11.247Z', 'display_name': 'Seaside', 'emojis': [], 'fields': [], 'followers_count': 61, 'following_count': 51, 'header': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/headers/000/048/702/original/739f6c295841b15d.jpg', 'header_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/headers/000/048/702/original/739f6c295841b15d.jpg', 'id': '48702', 'locked': False, 'note': '<p>KY/キモイ/陰キャ/クズ/玻璃心/NPC Energy</p>', 'statuses_count': 688, 'url': 'https://acg.mn/@air', 'username': 'air'}, 'created_at': '2019-04-28T03:00:00.900Z', 'id': '11701', 'type': 'follow'}]
由于要写的是迎新机器人,所以只需要读取 follow 的通知就好。这里有一个坑,Mastodon 在处理 array 参数(比如 foo = [1, 2, 3]
)的时候应该变成这样:foo[]=1&foo[]=2&foo[]=3
:
>>> pprint(requests.get('https://acg.mn/api/v1/notifications?exclude_types[]=favourite&exclude_types[]=reblog&exclude_types[]=mention', headers=headers).json()) [{'account': {'acct': 'air', 'avatar': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/048/702/original/80fe78f74efcf8f1.png', 'avatar_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/avatars/000/048/702/original/80fe78f74efcf8f1.png', 'bot': False, 'created_at': '2019-01-16T11:53:11.247Z', 'display_name': 'Seaside', 'emojis': [], 'fields': [], 'followers_count': 61, 'following_count': 51, 'header': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/headers/000/048/702/original/739f6c295841b15d.jpg', 'header_static': 'https://acg-mn.s3-ap-southeast-1.amazonaws.com/accounts/headers/000/048/702/original/739f6c295841b15d.jpg', 'id': '48702', 'locked': False, 'note': '<p>KY/キモイ/陰キャ/クズ/玻璃心/NPC Energy</p>', 'statuses_count': 688, 'url': 'https://acg.mn/@air', 'username': 'air'}, 'created_at': '2019-04-28T03:00:00.900Z', 'id': '11701', 'type': 'follow'}]
准备工作做得差不多了,可以动手了~
一把梭
Bot 的原理很简单:设置新用户自动关注 bot,写个死循环,每隔 30s 刷新一次通知,如果有新用户关注( ID 大于上一位)就发送欢迎词,为了方便起见还是留个配置文件和日志好了。考虑到 Bot 收到关注提醒的时候用户还没登录,30s 应该是可以接受的吧……
PS: 其实也不用这么麻烦,已经有很多 library 了,看到有人用 js 三行就搞定了……
因为用的是 Streaming API,PY 版本比较老,支持不太好 😪
源码示例:
#!/usr/bin/python3 import requests import json from time import sleep import logging from os.path import isfile logging.basicConfig(filename='newbot.log',level=logging.INFO) token = 'xxxxxxxx' instance = 'https://acg.mn' status = '''欢迎新人 %s ! 使用本站前请先阅读 Terms of Services: https://acg.mn/about/more Mastodon 是一个去中心化的社交网络,和传统的社交网络存在不少差异。为了更好地使用本站,建议下载客户端进行使用~有疑问可以戳 #长毛象中文使用指南 或向管理员 @air 提问!祝各位使用愉快!''' # Check if config file exists if isfile('newbot.json'): logging.info('Loading newbot.json...') config = json.load(open('newbot.json')) headers, since_id, last_user = config['headers'], config['since_id'], config['last_user'] logging.info('Success.') else: headers = {'Authorization': 'Bearer ' + token} since_id, last_user = '0', int(requests.get(instance + '/api/v1/accounts/verify_credentials', headers=headers).json()['id']) json.dump({'headers': headers, 'since_id': since_id, 'last_user': last_user}, open('newbot.json', 'w')) logging.info('Config file created successfully.') # Refresh and send welcome message def welcome(): global since_id, last_user followers = requests.get(instance + '/api/v1/notifications?limit=40&since_id=%s&exclude_types[]=favourite&exclude_types[]=reblog&exclude_types[]=mention' % since_id, headers=headers).json() newcomers = ' '.join([ '@' + i['account']['acct'] for i in followers if '@' not in i['account']['acct'] and int(i['account']['id']) > last_user]) if newcomers: _ = requests.post(instance + '/api/v1/statuses', headers=headers, data={'status': status % newcomers, 'visibility': 'unlisted'}).json() logging.info('%s Newcomers: %s' % (_['created_at'], newcomers)) since_id, last_user = followers[0]['id'], int(followers[0]['account']['id']) json.dump({'headers': headers, 'since_id': since_id, 'last_user': last_user}, open('newbot.json', 'w')) else: pass # Infinite loop while 1: try: welcome() except Exception as e: logging.info('%s: %s' % (type(e).__name__, str(e))) sleep(30)
把 token 和欢迎词换掉,再把 acg.mn 换成你的域名,最后:
$ vim newbot.py $ chmod +x newbot.py $ nohup ./newbot.py &
虽然觉得应该没 bug,还是先试运行几天好了😂