Compare commits

...

10 Commits

7 changed files with 214 additions and 24 deletions

5
.gitignore vendored
View File

@ -139,4 +139,7 @@ dmypy.json
cython_debug/
# config file
config.py
config.py
# file with pinned thread text
pinned_thread.txt

View File

@ -1,3 +1,33 @@
# mtg-card-lookup
A fediverse bot to look up Magic: The Gathering cards
A fediverse bot to look up Magic: The Gathering cards.
To use, mention the bot with a message that contains at least one Magic card name wrapped in either [[square brackets]] or {{curly braces}}. It will search Scryfall for the card and reply with the result. Alternatively, follow the bot, and it will follow you back. Any posts it sees with that syntax in its home timeline will also be processed.
See it in action [here](https://botsin.space/@mtgcardlookup)!
![](example-lotus.png)
## Features
- Automatically keeps its follower and following lists synced
- Follows back accounts instantly, unfollows every 5 minutes (API restriction)
- If four or less cards are requested, includes images of each card with Oracle text in the description
- Double Faced Card images are handled by combining both faces into a single image
- Uses fuzzy searching to correct shortenings where possible
- Replies at the same privacy level as it was called with, or unlisted, whichever is more restrictive
- Includes pinned status management (see "Installation and Setup")
- Unpins all current statuses, then posts a pre-written thread and pins each new status in reverse order
![](example-garruk.png)
## Installation and Setup
- Clone this repository and create a python environment however you like to
- Install dependencies with `pip install -r requirements.txt`
- Install and enable the included mtgcardlookup.service with systemd, replacing the `ExecStart`, `WorkingDirectory`, and `User` values with those for your system
- [OPTIONAL] Write an introduction thread to be pinned
- Create a new file called pinned_thread.txt
- Write each status of the introduction thread, separated by `\n-----\n`
- Run `./mtgcardlookup --update-pins`. The thread will be posted and pinned
- In the future, you can modify pinned_thread.txt and rerun the command. The existing thread will be unpinned and the modified one will be posted and pinned.

BIN
example-garruk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

BIN
example-lotus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

View File

@ -6,8 +6,8 @@ import asyncio
import aiohttp
import re
import json
import requests
import io
import argparse
import traceback
from PIL import Image
@ -25,7 +25,9 @@ from easter_eggs import eggs
from debug import *
async def startup():
async def startup(args):
"""Start up the entire bot, logging in and executing code"""
log('Starting up...')
if not os.path.exists('config.py'):
log('Config file not found, copying', Severity.WARNING)
@ -37,17 +39,83 @@ async def startup():
me = await c.verify_account_credentials()
log('Credentials verified!')
tasks = []
if args.update_pins:
await update_pins(c, me, config)
else:
tasks = [
asyncio.create_task(listen(c, me)),
asyncio.create_task(repeat(5 * 60, update_followers, c, me)),
]
tasks.append(asyncio.create_task(listen(c, me)))
tasks.append(asyncio.create_task(repeat(5 * 60, update_followers, c, me)))
for t in tasks:
await t
for t in tasks:
await t
async def update_pins(c, me, config):
"""Resend and repin the thread in pinned_thread.txt"""
async def get_pinned_statuses(user_id):
# atoot can't look up a user's pinned toots for some reason, so we
# have to do it ourselves
async with aiohttp.ClientSession() as session:
url = f'https://{config.instance}/api/v1/accounts/{me["id"]}/statuses?pinned=true'
async with session.get(url) as r:
return [e['id'] for e in await r.json()]
log('Ready to repost the pinned introduction thread!')
# Get text for each status of the thread
log('Getting thread text...')
try:
with open('pinned_thread.txt') as f:
thread_text = f.read().strip().split('\n-----\n')
except:
log('Error reading text for new pinned thread!', Severity.ERROR)
log('Make sure that the file pinned_thread.txt exists and that it contains the text of each status, separated by lines containing only "-----".', Severity.ERROR)
exit(-1)
thread_statuses = []
# Post the new thread
log('Posting new thread...')
for status_text in thread_text:
thread_statuses.append(
await c.create_status(
status=status_text,
in_reply_to_id=(thread_statuses[-1]['id'] if thread_statuses else None),
visibility='unlisted',
)
)
# Unpin all existing pins
log('Unpinning old thread...')
for status in (await get_pinned_statuses(me)):
await c.status_unpin(status)
log('Pinning new thread...')
# Pin the new thread in reverse order, so it reads chronologically top to bottom
for status in thread_statuses[::-1]:
await c.status_pin(status)
log('Done!')
async def get_cards(card_names):
"""
Return information about all cards with names in card_names
params: card_names (iterable with names of cards as strings)
return: [response, images]
where response is a list of strings, each being either:
the card name and scryfall url
"No card named {name} was found"
and where images is either:
a list of up to 4 tuples containing:
io.BytesIO images of cards
Oracle text for the card it's an image of
None
"""
async def get_card_image(session, c, get_oracle=True):
"""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:
@ -115,11 +183,8 @@ async def get_cards(card_names):
for name in card_names:
name = re.sub(r'<.*?>', '', name).strip()
# Handle set codes
if '|' in name:
name, set_code, *_ = name.split('|')
else:
set_code = ''
# Handle set codes and collector numbers
name, set_code, collector_num, *_ = (name.split('|') + ['']*2)
try:
# Check if any of the easter eggs should happen
@ -128,12 +193,28 @@ async def get_cards(card_names):
c = scrython.cards.Named(fuzzy=replacement)
break
else:
c = scrython.cards.Named(fuzzy=name, set=set_code)
c = scrython.cards.Named(fuzzy=name, set=set_code.lower())
if collector_num:
c_by_num = scrython.cards.Collector(
code=set_code.lower(),
collector_number=collector_num,
)
# Make sure the provided name matches
if c_by_num.name() != c.name():
raise scrython.foundation.ScryfallError
c = c_by_num
cards.append(c)
responses.append(f'{c.name()} - {c.scryfall_uri()}')
except scrython.foundation.ScryfallError:
if set_code:
except scrython.foundation.ScryfallError as e:
if collector_num:
responses.append(
f'No card named "{name}" from set with code '
f'{set_code.upper()} and collector number {collector_num} '
'was found.')
elif 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.')
@ -157,6 +238,13 @@ async def get_cards(card_names):
return responses, images
async def update_followers(c, me):
"""
Execute follows/unfollows to ensure that following and follower lists are synced
params: c (mastodon client object)
me (id of this bot's user account)
"""
log('Updating followed accounts...')
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))))
@ -187,6 +275,13 @@ async def update_followers(c, me):
log('No accounts to unfollow.')
async def handle_status(c, status):
"""
Determine if a status should be replied to and, if so, construct and post that reply
params: c (mastodon client object)
status (the status in question)
"""
# Ignore all reblogs
if status.get('reblog'): return
@ -197,19 +292,28 @@ async def handle_status(c, status):
# 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.
# This doesn't matter for, say, Mastodon. But apparently some fedi
# fedi displays public replies in public timelines? And even if that's
# wrong, it doesn't hurt to keep it this way, just in case.
reply_visibility = min(('unlisted', status_visibility), key=['direct', 'private', 'unlisted', 'public'].index)
media_ids = None
try:
card_names = re.findall(r'\[\[(.*?)\]\]', status_text)
card_names = re.findall(
r'''
(?:\[\[|\{\{) # A non-capturing group of the characters "[[" or "{{"
(.*?) # The card text being searched for (in a capturing group, so it's returned alone)
(?:\]\]|\}\}) # A non-capturing group of the characters "]]" or "}}"
''',
status_text,
re.VERBOSE)
# ignore any statuses without cards in them
if not card_names: return
log(f'Found a status with cards {card_names}...')
cards, media = await get_cards(card_names)
reply_text = status_author
@ -246,18 +350,32 @@ async def handle_status(c, status):
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):
"""
Follow back any users who follow
params: c (mastodon client object)
follow (the follow notification)
"""
id = follow['account']['id']
log(f'Received follow from {id}, following back')
await c.account_follow(id)
async def listen(c, me):
"""
Wait for incoming statuses and notifications and handle them appropriately
params: c (mastodon client object)
me (id of this bot's user account)
"""
log('Listening...')
async with c.streaming('user') as stream:
async for msg in stream:
event = msg.json()['event']
payload = json.loads(msg.json()['payload'])
# We only care about these two events
# We only care about 'update' and 'notification' events
if event == 'update':
mentions_me = any((mentioned['id'] == me['id'] for mentioned in payload['mentions']))
@ -287,4 +405,12 @@ async def repeat(interval, func, *args, **kwargs):
)
if __name__ == '__main__':
asyncio.run(startup())
parser = argparse.ArgumentParser()
parser.add_argument(
'--update-pins',
help='repost and repin the introduction thread from pinned_thread.txt, then exit',
action='store_true'
)
args = parser.parse_args()
asyncio.run(startup(args))

17
mtgcardlookup.service Normal file
View File

@ -0,0 +1,17 @@
[Unit]
Description=MTG Card Lookup Service
After=multi-user.target
StartLimitIntervalSec=0
[Service]
Type=idle
Restart=on-failure
RestartSec=5s
ExecStart=/PATH/TO/LOCAL/REPO/mtgcardlookup.py
WorkingDirectory=/PATH/TO/LOCAL/REPO/mtg-card-lookup/
User=YOUR_USERNAME
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target

14
requirements.txt Normal file
View File

@ -0,0 +1,14 @@
# For making asynchronous http requests
aiohttp
# Image processing, for combining the faces of DFCs
Pillow
# For connecting to a Mastodon instance
atoot
# For looking up Magic cards
scrython
# For using asyncio alongside Scrython
nest-asyncio