瞎写的 Mastodon 迎新机器人

瞎写的 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,还是先试运行几天好了😂