Add Mastodon bot to Ticketfrei 3 #2

Merged
missytake merged 32 commits from mastodon into development 2023-03-19 19:31:03 +00:00
3 changed files with 58 additions and 33 deletions
Showing only changes of commit dfd17aa27c - Show all commits

View file

@ -4,15 +4,15 @@
#
# SPDX-License-Identifier: 0BSD
from asyncio import get_event_loop, sleep
from kibicara.platformapi import Censor, Spawner, Message
from kibicara.platforms.mastodon.model import MastodonAccount
from logging import getLogger
from mastodon import Mastodon, MastodonError
from asyncio import gather
import re
from logging import getLogger
logger = getLogger(__name__)
@ -21,35 +21,49 @@ class MastodonBot(Censor):
super().__init__(mastodon_account_model.hood)
self.model = mastodon_account_model
self.enabled = self.model.enabled
self.polling_interval_sec = 60
@classmethod
async def destroy_hood(cls, hood):
"""Removes all its database entries."""
for mastodon in await Mastodon.objects.filter(hood=hood).all():
await mastodon.delete()
async def run(self):
await self.model.instance.load()
self.account = Mastodon(
client_id=self.model.instance.client_id,
client_secret=self.model.instance.client_secret,
api_base_url=self.model.instance.name,
access_token=self.model.access_token,
)
await gather(self.poll(), self.push())
try:
await self.model.instance.load()
self.account = Mastodon(
client_id=self.model.instance.client_id,
client_secret=self.model.instance.client_secret,
api_base_url=self.model.instance.name,
access_token=self.model.access_token,
)
account_details = await get_event_loop().run_in_executor(
None, self.account.account_verify_credentials
)
if username := account_details.get("username"):
await self.model.update(username=username)
await gather(self.poll(), self.push())
except Exception as e:
logger.debug("Bot {0} threw an Error: {1}".format(self.model.hood.name, e))
finally:
logger.debug("Bot {0} stopped.".format(self.model.hood.name))
async def poll(self):
"""Get new mentions and DMs from Mastodon"""
while True:
try:
notifications = self.account.notifications()
notifications = await get_event_loop().run_in_executor(
None, self.account.notifications
)
except MastodonError as e:
logger.warning("%s in hood %s" % (e, self.model.hood.name))
continue
last_seen = int(self.model.last_seen)
for status in notifications:
try:
status_id = int(status["status"]["id"])
except KeyError:
continue # ignore notifications which don't have a status
if status_id <= last_seen:
continue # toot was already processed in the past
if status_id > int(self.model.last_seen):
await self.model.update(last_seen=str(status_id))
text = re.sub(r"<[^>]*>", "", status["status"]["content"])
text = re.sub(
"(?<=^|(?<=[^a-zA-Z0-9-_.]))@([A-Za-z]+[A-Za-z0-9-_]+)", "", text
@ -62,6 +76,10 @@ class MastodonBot(Censor):
await self.publish(Message(text, toot_id=status_id))
else:
await self.publish(Message(text))
await get_event_loop().run_in_executor(
None, self.account.notifications_dismiss, status["id"]
)
Review

I like dismissing old notifications on the platform instead of keeping track of last_seen ourselves, but we didn't do this in ticketfrei2, so we need to dismiss all the past notifications during the migration.

I like dismissing old notifications on the platform instead of keeping track of `last_seen` ourselves, but we didn't do this in ticketfrei2, so we need to dismiss all the past notifications during the migration.
await sleep(self.polling_interval_sec)
Review

This is for rate limiting, I assume? We could maybe make this a bit more dynamic.

https://mastodonpy.readthedocs.io/en/stable/01_general.html#rate-limiting mentions that the default is 300 requests in 5 minutes, that would allow polling once per second instead of once per minute. But we don't know in advance how often we're gonna post, and the rate limit can be non-default.

Using the pace rate limit option is probably not possible with async? What we could use instead is the throw option to throw a MastodonRateLimitError if we run against the rate limit, request https://mastodonpy.readthedocs.io/en/stable/01_general.html#rate-limiting, and decide upon that for how long we do an await sleep().

This is for rate limiting, I assume? We could maybe make this a bit more dynamic. https://mastodonpy.readthedocs.io/en/stable/01_general.html#rate-limiting mentions that the default is 300 requests in 5 minutes, that would allow polling once per second instead of once per minute. But we don't know in advance how often we're gonna post, and the rate limit can be non-default. Using the `pace` rate limit option is probably not possible with async? What we could use instead is the `throw` option to throw a `MastodonRateLimitError` if we run against the rate limit, request https://mastodonpy.readthedocs.io/en/stable/01_general.html#rate-limiting, and decide upon that for how long we do an `await sleep()`.
Review
Or even better, use https://mastodonpy.readthedocs.io/en/stable/01_general.html#mastodon.Mastodon.ratelimit_remaining and https://mastodonpy.readthedocs.io/en/stable/01_general.html#mastodon.Mastodon.ratelimit_reset to guess how long we should sleep between requests, and if we hit a `MastodonRateLimitError`, we wait until https://mastodonpy.readthedocs.io/en/stable/01_general.html#mastodon.Mastodon.ratelimit_reset.
async def push(self):
"""Push new Ticketfrei reports to Mastodon; if source is mastodon, boost it."""
@ -69,10 +87,14 @@ class MastodonBot(Censor):
message = await self.receive()
if hasattr(message, "tood_id"):
logger.debug("Boosting post %s: %s" % (message.tood_id, message.text))
self.account.status_reblog(message.tood_id)
await get_event_loop().run_in_executor(
None, self.account.status_reblog, message.tood_id
)
else:
logger.debug("Posting message: %s" % (message.text,))
self.account.status_post(message.text)
await get_event_loop().run_in_executor(
None, self.account.status_post, message.text
)
spawner = Spawner(MastodonAccount, MastodonBot)

View file

@ -23,8 +23,8 @@ class MastodonAccount(Model):
hood: ForeignKey(Hood)
instance: ForeignKey(MastodonInstance)
access_token: Text()
username: Text(allow_null=True) = None
enabled: Boolean() = False
last_seen: Text()
class Mapping(Mapping):
table_name = "mastodonaccounts"

View file

@ -3,9 +3,11 @@
#
# SPDX-License-Identifier: 0BSD
from asyncio import get_event_loop
from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from pydantic import BaseModel, validate_email, validator
from sqlite3 import IntegrityError
from kibicara.config import config
from kibicara.platforms.mastodon.bot import spawner
@ -13,6 +15,7 @@ from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
from mastodon import Mastodon, MastodonError, MastodonNetworkError
from mastodon.errors import MastodonIllegalArgumentError
from logging import getLogger
@ -149,10 +152,7 @@ async def mastodon_stop(mastodon=Depends(get_mastodon)):
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
responses={
201: {"model": MastodonAccount},
422: {"model": HTTPError, "description": "Invalid Input"},
},
# TODO response_model
operation_id="create_mastodon",
)
async def mastodon_create(values: BodyMastodonAccount, hood=Depends(get_hood)):
@ -172,14 +172,17 @@ async def mastodon_create(values: BodyMastodonAccount, hood=Depends(get_hood)):
instance.client_id, instance.client_secret, api_base_url=values.instance_url
)
try:
access_token = account.log_in(values.email, values.password)
except MastodonError:
access_token = await get_event_loop().run_in_executor(
None, account.log_in, username, password
)
logger.debug(f"{access_token}")
mastodon = await MastodonAccount.objects.create(
hood=hood, instance=instance, access_token=access_token, enabled=True
)
spawner.start(mastodon)
return mastodon
except MastodonIllegalArgumentError:
logger.warning("Login to Mastodon failed.", exc_info=True)
return HTTPException(422, "Login to Mastodon failed")
return await MastodonAccount.objects.create(
hood=hood,
instance=instance,
access_token=access_token,
enabled=True,
last_seen="0",
)
raise HTTPException(status_code=status.HTTP_422_INVALID_INPUT)
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)