Compare commits

...

9 commits

8 changed files with 284 additions and 2 deletions

View file

@ -33,6 +33,7 @@
3. Install the dependencies with `npm i`
4. Install Angular with `npm i -g @angular/cli`
5. Turn off production mode if you have not already (see above in backend).
6. Also make sure to disable strict CORS checking for testing: `sudo su -c 'echo "cors_allow_origin = \"*\"" >> /etc/kibicara.conf'`
6. Start the backend in a different terminal
7. To serve and open the application, run `ng s -o`. The application will open
under [http://127.0.0.1:4200](http://127.0.0.1:4200).

View file

@ -26,7 +26,7 @@ from pytoml import load
config = {
'database_connection': 'sqlite:////tmp/kibicara.sqlite',
'frontend_url': 'http://127.0.0.1:4200', # url of frontend, change in prod
'frontend_url': 'http://localhost:4200', # url of frontend, change in prod
'secret': random(SecretBox.KEY_SIZE).hex(), # generate with: openssl rand -hex 32
# production params
'frontend_path': None, # required, path to frontend html/css/js files
@ -36,7 +36,7 @@ config = {
'certfile': None, # optional for ssl
# dev params
'root_url': 'http://localhost:8000', # url of backend
'cors_allow_origin': 'http://127.0.0.1:4200',
'cors_allow_origin': 'http://localhost:4200',
}
"""Default configuration.

View file

View file

@ -0,0 +1,78 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from kibicara.platformapi import Censor, Spawner, Message
from kibicara.platforms.mastodon.model import MastodonAccount
from mastodon import Mastodon, MastodonError
from asyncio import gather
import re
from logging import getLogger
logger = getLogger(__name__)
class MastodonBot(Censor):
def __init__(self, mastodon_account_model):
super().__init__(mastodon_account_model.hood)
self.model = mastodon_account_model
self.enabled = self.model.enabled
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())
async def poll(self):
"""Get new mentions and DMs from Mastodon"""
while True:
try:
notifications = 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
)
logger.debug(
"Mastodon in %s received toot #%s: %s"
% (self.model.hood.name, status_id, text)
)
if status['status']['visibility'] == 'public':
await self.publish(Message(text, toot_id=status_id))
else:
await self.publish(Message(text))
async def push(self):
"""Push new Ticketfrei reports to Mastodon; if source is mastodon, boost it."""
while True:
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)
else:
logger.debug("Posting message: %s" % (message.text,))
self.account.status_post(message.text)
spawner = Spawner(MastodonAccount, MastodonBot)

View file

@ -0,0 +1,30 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from ormantic import ForeignKey, Integer, Text, Boolean, Model
from kibicara.model import Hood, Mapping
class MastodonInstance(Model):
id: Integer(primary_key=True) = None
name: Text()
client_id: Text()
client_secret: Text()
class Mapping(Mapping):
table_name = 'mastodoninstances'
class MastodonAccount(Model):
id: Integer(primary_key=True) = None
hood: ForeignKey(Hood)
instance: ForeignKey(MastodonInstance)
access_token: Text()
enabled: Boolean() = False
last_seen: Text()
class Mapping(Mapping):
table_name = 'mastodonaccounts'

View file

@ -0,0 +1,168 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from pydantic import BaseModel
from kibicara.config import config
from kibicara.platforms.mastodon.bot import spawner
from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
from mastodon import Mastodon, MastodonError
from logging import getLogger
logger = getLogger(__name__)
class BodyMastodonPublic(BaseModel):
username: str
instance: str
async def get_mastodon(mastodon_id, hood=Depends(get_hood)):
try:
return await MastodonAccount.objects.get(id=mastodon_id, hood=hood)
except NoMatch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
async def get_mastodon_instance(instance_url: str) -> MastodonInstance:
"""Return a MastodonInstance ORM object with valid client_id and client_secret.
:param: instance_url: the API base URL of the mastodon server
:return the MastodonInstance ORM object
"""
try:
return await MastodonInstance.objects.get(name=instance_url)
except NoMatch:
app_name = config.get("frontend_url")
client_id, client_secret = Mastodon.create_app(
app_name, api_base_url=instance_url
)
await MastodonInstance.objects.create(
name=instance_url, client_id=client_id, client_secret=client_secret
)
return await MastodonInstance.objects.get(name=instance_url)
router = APIRouter()
twitter_callback_router = APIRouter()
@router.get(
'/public',
# TODO response_model,
operation_id='get_mastodons_public',
)
async def mastodon_read_all_public(hood=Depends(get_hood_unauthorized)):
mastodonbots = await MastodonAccount.objects.filter(hood=hood).all()
return [
BodyMastodonPublic(username=mbot.username, instance=mbot.model.instance.name)
for mbot in mastodonbots
if mbot.enabled == 1 and mbot.username
]
@router.get(
'/',
# TODO response_model,
operation_id='get_mastodons',
)
async def mastodon_read_all(hood=Depends(get_hood)):
return await MastodonAccount.objects.filter(hood=hood).all()
@router.get(
'/{mastodon_id}',
# TODO response_model
operation_id='get_mastodon',
)
async def mastodon_read(mastodon=Depends(get_mastodon)):
return mastodon
@router.delete(
'/{mastodon_id}',
status_code=status.HTTP_204_NO_CONTENT,
# TODO response_model
operation_id='delete_mastodon',
)
async def mastodon_delete(mastodon=Depends(get_mastodon)):
spawner.stop(mastodon)
await mastodon.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
'/{mastodon_id}/status',
status_code=status.HTTP_200_OK,
# TODO response_model
operation_id='status_mastodon',
)
async def mastodon_status(mastodon=Depends(get_mastodon)):
return {'status': spawner.get(mastodon).status.name}
@router.post(
'/{mastodon_id}/start',
status_code=status.HTTP_200_OK,
# TODO response_model
operation_id='start_mastodon',
)
async def mastodon_start(mastodon=Depends(get_mastodon)):
await mastodon.update(enabled=True)
spawner.get(mastodon).start()
return {}
@router.post(
'/{mastodon_id}/stop',
status_code=status.HTTP_200_OK,
# TODO response_model
operation_id='stop_mastodon',
)
async def mastodon_stop(mastodon=Depends(get_mastodon)):
await mastodon.update(enabled=False)
spawner.get(mastodon).stop()
return {}
@router.post(
'/',
status_code=status.HTTP_201_CREATED,
# TODO response_model
operation_id='create_mastodon',
)
async def mastodon_create(instance_url, username, password, hood=Depends(get_hood)):
"""Add a Mastodon Account to a Ticketfrei account.
open questions:
do we really get the username + password like this?
can the instance_url have different ways of writing?
:param: instance_url: the API base URL of the mastodon server
:param: username: the username of the Mastodon account
:param: password: the password of the Mastodon account
:param: hood: the hood ORM object
"""
instance = await get_mastodon_instance(instance_url)
account = Mastodon(
instance.client_id, instance.client_secret, api_base_url=instance_url
)
try:
access_token = account.log_in(username, password)
except MastodonError:
logger.warning("Login to Mastodon failed.", exc_info=True)
return # show error to user
return await MastodonAccount.objects.create(
hood=hood,
instance=instance,
access_token=access_token,
enabled=True,
last_seen="0",
)

View file

@ -15,6 +15,7 @@ from fastapi import APIRouter
from kibicara.platforms.email.webapi import router as email_router
from kibicara.platforms.telegram.webapi import router as telegram_router
from kibicara.platforms.test.webapi import router as test_router
from kibicara.platforms.mastodon.webapi import router as mastodon_router
from kibicara.platforms.twitter.webapi import router as twitter_router
from kibicara.platforms.twitter.webapi import twitter_callback_router
from kibicara.webapi.admin import router as admin_router
@ -37,6 +38,9 @@ hoods_router.include_router(
hoods_router.include_router(
twitter_router, prefix='/{hood_id}/twitter', tags=['twitter']
)
hoods_router.include_router(
mastodon_router, prefix='/{hood_id}/mastodon', tags=['mastodon']
)
router.include_router(twitter_callback_router, prefix='/twitter', tags=['twitter'])
hoods_router.include_router(email_router, prefix='/{hood_id}/email', tags=['email'])
router.include_router(hoods_router, prefix='/hoods')

View file

@ -29,5 +29,6 @@ setup(
'pytoml',
'requests',
'scrypt',
'Mastodon.py',
],
)