Add Mastodon bot to Ticketfrei 3 #2
|
@ -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"]
|
||||
)
|
||||
|
||||
await sleep(self.polling_interval_sec)
|
||||
missytake
commented
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 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()`.
missytake
commented
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 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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
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.