2021-03-09 06:25:13 +00:00
#!/usr/bin/env python3.9
2021-03-09 06:05:42 +00:00
2021-03-09 17:49:15 +00:00
import os # Used exactly once to check for the config file
import shutil # Used exactly once to copy the sample config file
2021-03-09 04:32:26 +00:00
import asyncio
2021-03-09 18:40:55 +00:00
import aiohttp
2021-03-09 05:08:20 +00:00
import re
import json
2021-03-09 06:01:09 +00:00
import requests
import io
2021-03-09 15:46:51 +00:00
import traceback
2021-03-28 00:54:49 +00:00
from PIL import Image
2021-03-09 04:32:26 +00:00
2021-03-09 17:49:15 +00:00
import atoot # Asynchronous Mastodon API wrapper
import scrython # Scryfall API (similar to Gatherer but I prefer Scryfall)
import nest_asyncio # I cannot even begin to explain why I need this one.
nest_asyncio . apply ( ) # It has something to do with Scrython using asyncio, which means I can't
# use it from within my asyncio code. And that's on purpose, I think.
# But this patches asyncio to allow that, somehow?
# I'm sorry, it's completely beyond me. Look it up.
2021-03-09 04:32:26 +00:00
2021-03-09 16:12:59 +00:00
import face
2021-03-09 04:32:26 +00:00
from debug import *
async def startup ( ) :
log ( ' Starting up... ' )
if not os . path . exists ( ' config.py ' ) :
log ( ' Config file not found, copying ' , Severity . WARNING )
shutil . copyfile ( ' config.sample.py ' , ' config.py ' )
2021-03-09 14:20:30 +00:00
2021-03-09 04:32:26 +00:00
import config
async with atoot . client ( config . instance , access_token = config . access_token ) as c :
log ( ' Connected to server! ' )
me = await c . verify_account_credentials ( )
log ( ' Credentials verified! ' )
2021-03-09 14:20:30 +00:00
2021-03-09 04:32:26 +00:00
tasks = [ ]
2021-03-09 14:20:30 +00:00
2021-03-09 04:32:26 +00:00
tasks . append ( asyncio . create_task ( listen ( c , me ) ) )
tasks . append ( asyncio . create_task ( repeat ( 5 * 60 , update_followers , c , me ) ) )
2021-03-09 14:20:30 +00:00
2021-03-09 04:32:26 +00:00
for t in tasks :
await t
2021-03-09 15:46:51 +00:00
async def get_cards ( card_names ) :
2021-03-28 00:54:49 +00:00
async def get_card_image ( session , c ) :
2021-12-11 06:48:21 +00:00
""" Return card image and description (text representation) """
async def download_card_image ( session , c ) :
async with session . get ( c . image_uris ( 0 , ' normal ' ) ) as r :
log ( f ' Downloading image for { c . name ( ) } ... ' )
2021-03-28 00:54:49 +00:00
# BytesIO stores the data in memory as a file-like object.
# We can turn around and upload it to fedi without ever
# touching the disk.
2021-12-11 06:48:21 +00:00
b = await r . read ( )
log ( f ' Done downloading image for { c . name ( ) } ! ' )
return io . BytesIO ( b )
async def download_card_text ( session , c ) :
async with session . get ( c . uri ( ) + ' ?format=text ' ) as r :
log ( f ' Downloading text representation of { c . name ( ) } ... ' )
text = await r . text ( )
log ( f ' Done downloading text representation of { c . name ( ) } ! ' )
return text
2021-03-28 00:54:49 +00:00
try :
front , back = map ( Image . open , await asyncio . gather (
* ( get_card_image ( session , face . Face ( card_face ) ) for card_face in c . card_faces ( ) )
) )
new_image = Image . new ( ' RGB ' , ( front . width * 2 , front . height ) )
new_image . paste ( front , ( 0 , 0 ) )
new_image . paste ( back , ( front . width , 0 ) )
output = io . BytesIO ( )
new_image . save ( output , format = front . format )
output . seek ( 0 )
return output
except ( AttributeError , KeyError ) :
pass
2021-12-11 06:48:21 +00:00
return await asyncio . gather (
download_card_image ( session , c ) ,
download_card_text ( session , c )
)
2021-03-09 15:08:25 +00:00
2021-03-09 17:49:15 +00:00
# Responses list: One entry for each [[card name]] in parent, even if the
# response is just "No card named 'CARDNAME' was found."
responses = [ ]
# Cards list: Only cards that were found successfully
2021-03-09 06:01:09 +00:00
cards = [ ]
2021-03-09 14:20:30 +00:00
2021-03-09 06:01:09 +00:00
for name in card_names :
2021-03-10 03:45:14 +00:00
name = re . sub ( r ' <.*?> ' , ' ' , name ) . strip ( )
2021-03-26 22:57:10 +00:00
2021-03-26 02:44:36 +00:00
# Handle set codes
if ' | ' in name :
name , set_code , * _ = name . split ( ' | ' )
else :
set_code = ' '
2021-03-26 22:57:10 +00:00
2021-03-09 06:01:09 +00:00
try :
2021-03-10 03:28:59 +00:00
if len ( name ) > 141 :
2021-03-16 21:07:37 +00:00
c = scrython . cards . Named ( fuzzy = ' Our Market Research Shows That Players Like Really Long Card Names So We Made this Card to Have the Absolute Longest Card Name Ever Elemental ' )
2021-03-10 16:50:11 +00:00
elif len ( name ) == 0 :
c = scrython . cards . Named ( fuzzy = ' _____ ' )
2021-03-10 03:28:59 +00:00
else :
2021-03-26 02:44:36 +00:00
c = scrython . cards . Named ( fuzzy = name , set = set_code )
2021-03-09 17:49:15 +00:00
cards . append ( c )
responses . append ( f ' { c . name ( ) } - { c . scryfall_uri ( ) } ' )
2021-03-09 06:01:09 +00:00
except scrython . foundation . ScryfallError :
2021-03-26 02:44:36 +00:00
if set_code :
responses . append ( f ' No card named " { name } " from set with code { set_code . upper ( ) } was found. ' )
else :
responses . append ( f ' No card named " { name } " was found. ' )
2021-03-09 17:49:15 +00:00
# Download card images.
# A status can only have four images on it, so we can't necessarily include
# every card mentioned in the status.
# The reason I choose to include /no/ images in that case is that someone
# linking to more than 4 cards is probably talking about enough different things
# that it would be weird for the first four they happened to mention to have images.
# Like if someone's posting a decklist it would be weird for the first four cards to
# be treated as special like that.
if 1 < = len ( cards ) < = 4 :
2021-03-09 15:08:25 +00:00
async with aiohttp . ClientSession ( ) as session :
2021-12-11 06:48:21 +00:00
images = await asyncio . gather (
* ( get_card_image ( session , c ) for c in cards )
)
2021-03-09 15:08:25 +00:00
else :
images = None
2021-03-09 14:20:30 +00:00
2021-03-09 17:56:48 +00:00
return responses , images
2021-03-09 06:01:09 +00:00
2021-03-09 04:32:26 +00:00
async def update_followers ( c , me ) :
log ( ' Updating followed accounts... ' )
2021-03-10 16:47:20 +00:00
accounts_following_me = set ( map ( lambda a : a [ ' id ' ] , await c . get_all ( c . account_followers ( me ) ) ) )
accounts_i_follow = set ( map ( lambda a : a [ ' id ' ] , await c . get_all ( c . account_following ( me ) ) ) )
2021-03-09 14:20:30 +00:00
2021-03-09 17:49:15 +00:00
# Accounts that follow me that I don't follow
2021-03-09 04:32:26 +00:00
to_follow = accounts_following_me - accounts_i_follow
2021-03-09 14:20:30 +00:00
2021-03-09 17:49:15 +00:00
# Accounts I follow that don't follow me
2021-03-09 04:32:26 +00:00
to_unfollow = accounts_i_follow - accounts_following_me
2021-03-09 14:20:30 +00:00
2021-03-09 04:32:26 +00:00
if to_follow :
2021-03-10 03:18:44 +00:00
# Note that the bot listens for follows and tries to follow
2021-03-09 17:49:15 +00:00
# back instantly. This is /usually/ dead code but it's a failsafe
# in case someone followed while the bot was down or something.
2021-03-09 04:32:26 +00:00
log ( f ' { len ( to_follow ) } accounts to follow: ' )
for account in to_follow :
await c . account_follow ( account )
log ( f ' Followed { account } ' )
else :
log ( ' No accounts to follow. ' )
2021-03-09 14:20:30 +00:00
2021-03-09 04:32:26 +00:00
if to_unfollow :
log ( f ' { len ( to_unfollow ) } accounts to unfollow: ' )
for account in to_unfollow :
await c . account_unfollow ( account )
log ( f ' Unfollowed { account } ' )
else :
log ( ' No accounts to unfollow. ' )
2021-03-26 22:57:10 +00:00
async def handle_status ( c , status ) :
# Ignore all reblogs
if status . get ( ' reblog ' ) : return
status_id = status [ ' id ' ]
status_author = ' @ ' + status [ ' account ' ] [ ' acct ' ]
status_text = status [ ' content ' ]
status_visibility = status [ ' visibility ' ]
# Reply unlisted or at the same visibility as the parent, whichever is
# more restrictive
# I realized after writing this that I don't /think/ it ever matters?
# I think replies behave the same on public and unlisted. But I'm not 100%
# sure so it stays.
reply_visibility = min ( ( ' unlisted ' , status_visibility ) , key = [ ' direct ' , ' private ' , ' unlisted ' , ' public ' ] . index )
media_ids = None
try :
card_names = re . findall ( r ' \ [ \ [(.*?) \ ] \ ] ' , status_text )
# ignore any statuses without cards in them
if not card_names : return
cards , media = await get_cards ( card_names )
reply_text = status_author
# Just a personal preference thing. If I ask for one card, put the
# text on the same line as the mention. If I ask for more, start the
# list a couple of lines down.
if len ( cards ) == 1 :
reply_text + = ' ' + cards [ 0 ]
else :
reply_text + = ' \n \n ' + ' \n ' . join ( cards )
if media :
try :
media_ids = [ ]
for image , desc in media :
media_ids . append ( ( await c . upload_attachment ( fileobj = image , params = { } , description = desc ) ) [ ' id ' ] )
except atoot . api . RatelimitError :
media_ids = None
reply_text + = ' \n \n Media attachments are temporarily disabled due to API restrictions, they will return shortly. '
except Exception as e :
# Oops!
log ( traceback . print_exc ( ) , Severity . ERROR )
reply_text = f ' { status_author } Sorry! You broke me somehow. Please let Holly know what you did! '
log ( ' Sending reply... ' )
try :
reply = await c . create_status ( status = reply_text , media_ids = media_ids , in_reply_to_id = status_id , visibility = reply_visibility )
log ( f ' Reply sent! { reply [ " uri " ] } ' )
except atoot . api . UnprocessedError as e :
log ( f ' Could not send reply! ' , Severity . ERROR )
log ( traceback . format_exc ( ) , Severity . ERROR )
error_msg = ' An error occured sending the reply. This most likely means that it would have been greater than 500 characters. If it was something else, please let Holly know! '
await c . create_status ( status = f ' { status_author } { error_msg } ' , in_reply_to_id = status_id , visibility = reply_visibility )
async def handle_follow ( c , follow ) :
id = follow [ ' account ' ] [ ' id ' ]
log ( f ' Received follow from { id } , following back ' )
await c . account_follow ( id )
2021-03-09 04:32:26 +00:00
async def listen ( c , me ) :
log ( ' Listening... ' )
async with c . streaming ( ' user ' ) as stream :
async for msg in stream :
2021-03-26 22:57:10 +00:00
event = msg . json ( ) [ ' event ' ]
payload = json . loads ( msg . json ( ) [ ' payload ' ] )
# We only care about these two events
if event == ' update ' :
mentions_me = any ( ( mentioned [ ' id ' ] == me [ ' id ' ] for mentioned in payload [ ' mentions ' ] ) )
# Ignore any incoming status that mentions us
# We're also going to get a ntification event, we'll handle it there
if not mentions_me :
await handle_status ( c , payload )
elif event == ' notification ' :
if payload [ ' type ' ] == ' follow ' :
await handle_follow ( c , payload )
elif payload [ ' type ' ] == ' mention ' :
await handle_status ( c , payload [ ' status ' ] )
2021-03-09 04:32:26 +00:00
# https://stackoverflow.com/a/55505152/2114129
async def repeat ( interval , func , * args , * * kwargs ) :
2021-03-09 17:49:15 +00:00
""" Run func every interval seconds.
If func has not finished before * interval * , will run again
immediately when the previous iteration finished .
* args and * * kwargs are passed as the arguments to func .
"""
while True :
await asyncio . gather (
func ( * args , * * kwargs ) ,
asyncio . sleep ( interval ) ,
)
2021-03-09 04:32:26 +00:00
if __name__ == ' __main__ ' :
2021-03-09 06:05:42 +00:00
asyncio . run ( startup ( ) )