Allow users to request a rescan of a message that was missed
This commit is contained in:
parent
9688645864
commit
08b001e38f
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
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.
|
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. Finally, if you made a post that you intended to be scanned, but wasn't because you forgot to mention the bot, reply to that post while mentioning the bot and the text "last", "back", "parent" or "up".
|
||||||
|
|
||||||
See it in action [here](https://botsin.space/@mtgcardlookup)!
|
See it in action [here](https://botsin.space/@mtgcardlookup)!
|
||||||
|
|
||||||
|
|
136
mtgcardlookup.py
136
mtgcardlookup.py
|
@ -3,12 +3,12 @@
|
||||||
import os # Used exactly once to check for the config file
|
import os # Used exactly once to check for the config file
|
||||||
import shutil # Used exactly once to copy the sample config file
|
import shutil # Used exactly once to copy the sample config file
|
||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import io
|
import io
|
||||||
import argparse
|
import argparse
|
||||||
import traceback
|
import traceback
|
||||||
|
import nh3
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
import atoot # Asynchronous Mastodon API wrapper
|
import atoot # Asynchronous Mastodon API wrapper
|
||||||
|
@ -53,13 +53,11 @@ async def startup(args):
|
||||||
async def update_pins(c, me, config):
|
async def update_pins(c, me, config):
|
||||||
"""Resend and repin the thread in pinned_thread.txt"""
|
"""Resend and repin the thread in pinned_thread.txt"""
|
||||||
|
|
||||||
async def get_pinned_statuses(user_id):
|
async def get_pinned_statuses(c, user_id):
|
||||||
# atoot can't look up a user's pinned toots for some reason, so we
|
# atoot can't look up a user's pinned toots, so we
|
||||||
# have to do it ourselves
|
# have to do it ourselves
|
||||||
async with aiohttp.ClientSession() as session:
|
statuses = await c.get(f'/api/v1/accounts/{me["id"]}/statuses?pinned=true')
|
||||||
url = f'https://{config.instance}/api/v1/accounts/{me["id"]}/statuses?pinned=true'
|
return (s['id'] for s in statuses)
|
||||||
async with session.get(url) as r:
|
|
||||||
return [e['id'] for e in await r.json()]
|
|
||||||
|
|
||||||
log('Ready to repost the pinned introduction thread!')
|
log('Ready to repost the pinned introduction thread!')
|
||||||
|
|
||||||
|
@ -88,7 +86,7 @@ async def update_pins(c, me, config):
|
||||||
|
|
||||||
# Unpin all existing pins
|
# Unpin all existing pins
|
||||||
log('Unpinning old thread...')
|
log('Unpinning old thread...')
|
||||||
for status in (await get_pinned_statuses(me)):
|
for status in (await get_pinned_statuses(c, me)):
|
||||||
await c.status_unpin(status)
|
await c.status_unpin(status)
|
||||||
|
|
||||||
log('Pinning new thread...')
|
log('Pinning new thread...')
|
||||||
|
@ -274,28 +272,103 @@ async def update_followers(c, me):
|
||||||
else:
|
else:
|
||||||
log('No accounts to unfollow.')
|
log('No accounts to unfollow.')
|
||||||
|
|
||||||
async def handle_status(c, status):
|
async def handle_status(c, status, mentions_me, me):
|
||||||
"""
|
"""
|
||||||
Determine if a status should be replied to and, if so, construct and post that reply
|
Determine if a status should be replied to and, if so, construct and post that reply
|
||||||
|
|
||||||
params: c (mastodon client object)
|
params: c (mastodon client object)
|
||||||
status (the status in question)
|
status (the status in question)
|
||||||
|
mentions_me (bool, whether or not the status mentions this bot)
|
||||||
|
me (id of this bot's user account)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def get_status_info(status):
|
||||||
|
status_id = status['id']
|
||||||
|
status_author = '@' + status['account']['acct']
|
||||||
|
status_text = nh3.clean(status['content'], tags=set()) # Remove all HTML from the status text
|
||||||
|
status_visibility = status['visibility']
|
||||||
|
|
||||||
|
# Reply unlisted or at the same visibility as the parent, whichever is
|
||||||
|
# more restrictive
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
return status_id, status_author, status_text, status_visibility, reply_visibility
|
||||||
|
|
||||||
|
async def get_our_non_error_replies(status_id, me):
|
||||||
|
all_descendants = (await c.get(f'/api/v1/statuses/{status_id}/context'))['descendants']
|
||||||
|
replies = (s for s in all_descendants if s['in_reply_to_id'] == status_id)
|
||||||
|
ours = (s for s in replies if s['account']['id'] == me["id"])
|
||||||
|
return (s for s in ours if 'Sorry! You broke me somehow.' not in s['content'])
|
||||||
|
|
||||||
|
async def send_reply(c, reply_text, media_ids, status_id, reply_visibility):
|
||||||
|
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)
|
||||||
|
|
||||||
# Ignore all reblogs
|
# Ignore all reblogs
|
||||||
if status.get('reblog'): return
|
if status.get('reblog'): return
|
||||||
|
|
||||||
status_id = status['id']
|
status_id, status_author, status_text, status_visibility, reply_visibility = get_status_info(status)
|
||||||
status_author = '@' + status['account']['acct']
|
reply_text = status_author
|
||||||
status_text = status['content']
|
|
||||||
status_visibility = status['visibility']
|
# The bot can be called on the parent status if:
|
||||||
|
# - The only non-mention text in the current status is "last", "back", "parent" or "up"
|
||||||
# Reply unlisted or at the same visibility as the parent, whichever is
|
# - We are explicitly mentioned in the status
|
||||||
# more restrictive
|
# - There is a parent status
|
||||||
# This doesn't matter for, say, Mastodon. But apparently some fedi
|
# - We can access the parent status
|
||||||
# fedi displays public replies in public timelines? And even if that's
|
# - That parent status was written by the same user as the current status
|
||||||
# wrong, it doesn't hurt to keep it this way, just in case.
|
# - This bot hasn't already replied to the parent status, unless that reply was an error
|
||||||
reply_visibility = min(('unlisted', status_visibility), key=['direct', 'private', 'unlisted', 'public'].index)
|
if mentions_me and re.sub(r'\S*@\S*', '', status_text).strip().lower() in ('last', 'back', 'parent', 'up'):
|
||||||
|
log(f'Trying to handle parent of status {status_id}')
|
||||||
|
# First two checks passed, the rest is a little complicated
|
||||||
|
|
||||||
|
# Ensure that there is a parent
|
||||||
|
if status['in_reply_to_account_id'] is None:
|
||||||
|
log(f'There is no parent stauts, bail')
|
||||||
|
await send_reply(c, f'{status_author} This message isn\'t a reply!', None, status_id, reply_visibility)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure that the parent message is reachable
|
||||||
|
parent_id = status['in_reply_to_id']
|
||||||
|
try:
|
||||||
|
log(f'Try to get the parent status with id {parent_id}')
|
||||||
|
reply_status = status
|
||||||
|
status = await c.get(f'/api/v1/statuses/{parent_id}')
|
||||||
|
except atoot.api.NotFoundError:
|
||||||
|
log(f'Can\'t get the parent status, bail')
|
||||||
|
await send_reply(
|
||||||
|
c,
|
||||||
|
f'{status_author} I can\'t see the parent status! It\'s probably followers-only or a DM, and I don\'t have permission to view it.',
|
||||||
|
None,
|
||||||
|
status_id,
|
||||||
|
reply_visibility,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Enusre that the authors match
|
||||||
|
if reply_status['account']['id'] != status['account']['id']:
|
||||||
|
log(f'Author ID {reply_status["account"]["id"]} doesn\'t match parent author id {status["account"]["id"]}, bail')
|
||||||
|
await send_reply(c, f'{status_author} This operation can only be performed on your own status!', None, status_id, reply_visibility)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure that we haven't already replied to the parent
|
||||||
|
if (replies := tuple(s for s in await get_our_non_error_replies(status['id'], me))):
|
||||||
|
existing_reply = replies[0]
|
||||||
|
log(f'We\'ve already responded to this parent at {existing_reply["id"]}, bail')
|
||||||
|
await send_reply(c, f'{status_author} I\'ve already replied to the parent status! {existing_reply["url"]}', None, status_id, reply_visibility)
|
||||||
|
return
|
||||||
|
|
||||||
|
log('Checks all passed, running remaining function on parent status')
|
||||||
|
status_id, status_author, status_text, status_visibility, reply_visibility = get_status_info(status)
|
||||||
|
reply_text = f'{status_author} [Retroactive scan requested]'
|
||||||
|
|
||||||
media_ids = None
|
media_ids = None
|
||||||
|
|
||||||
|
@ -316,12 +389,11 @@ async def handle_status(c, status):
|
||||||
|
|
||||||
cards, media = await get_cards(card_names)
|
cards, media = await get_cards(card_names)
|
||||||
|
|
||||||
reply_text = status_author
|
|
||||||
|
|
||||||
# Just a personal preference thing. If I ask for one card, put the
|
# 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
|
# text on the same line as the mention. If I ask for more, start the
|
||||||
# list a couple of lines down.
|
# list a couple of lines down. On a retroactive scan, always start a
|
||||||
if len(cards) == 1:
|
# couple lines down.
|
||||||
|
if len(cards) == 1 and '[Retroactive scan requested]' not in reply_text:
|
||||||
reply_text += ' ' + cards[0]
|
reply_text += ' ' + cards[0]
|
||||||
else:
|
else:
|
||||||
reply_text += '\n\n' + '\n'.join(cards)
|
reply_text += '\n\n' + '\n'.join(cards)
|
||||||
|
@ -340,14 +412,7 @@ async def handle_status(c, status):
|
||||||
reply_text = f'{status_author} Sorry! You broke me somehow. Please let Holly know what you did!'
|
reply_text = f'{status_author} Sorry! You broke me somehow. Please let Holly know what you did!'
|
||||||
|
|
||||||
log('Sending reply...')
|
log('Sending reply...')
|
||||||
try:
|
await send_reply(c, reply_text, media_ids, status_id, reply_visibility)
|
||||||
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):
|
async def handle_follow(c, follow):
|
||||||
"""
|
"""
|
||||||
|
@ -380,14 +445,15 @@ async def listen(c, me):
|
||||||
mentions_me = any((mentioned['id'] == me['id'] for mentioned in payload['mentions']))
|
mentions_me = any((mentioned['id'] == me['id'] for mentioned in payload['mentions']))
|
||||||
|
|
||||||
# Ignore any incoming status that mentions us
|
# Ignore any incoming status that mentions us
|
||||||
# We're also going to get a ntification event, we'll handle it there
|
# We're also going to get a notification event, we'll handle it there
|
||||||
if not mentions_me:
|
if not mentions_me:
|
||||||
await handle_status(c, payload)
|
await handle_status(c, payload, False, me)
|
||||||
|
|
||||||
elif event == 'notification':
|
elif event == 'notification':
|
||||||
if payload['type'] == 'follow':
|
if payload['type'] == 'follow':
|
||||||
await handle_follow(c, payload)
|
await handle_follow(c, payload)
|
||||||
elif payload['type'] == 'mention':
|
elif payload['type'] == 'mention':
|
||||||
await handle_status(c, payload['status'])
|
await handle_status(c, payload['status'], True, me)
|
||||||
|
|
||||||
# https://stackoverflow.com/a/55505152/2114129
|
# https://stackoverflow.com/a/55505152/2114129
|
||||||
async def repeat(interval, func, *args, **kwargs):
|
async def repeat(interval, func, *args, **kwargs):
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# For making asynchronous http requests
|
# For stripping HTML from status content
|
||||||
aiohttp
|
nh3
|
||||||
|
|
||||||
# Image processing, for combining the faces of DFCs
|
# Image processing, for combining the faces of DFCs
|
||||||
Pillow
|
Pillow
|
||||||
|
|
Loading…
Reference in New Issue