diff --git a/kibicara/config.py b/kibicara/config.py index b2cacfc..c2333fd 100644 --- a/kibicara/config.py +++ b/kibicara/config.py @@ -4,6 +4,19 @@ # # SPDX-License-Identifier: 0BSD +""" Configuration file and command line argument parser. + +Gives a dictionary named `config` with configuration parsed either from +`/etc/kibicara.conf` or from a file given by command line argument `-f`. +If no configuration was found at all, the defaults are used. + +Example: + ``` + from kibicara.config import config + print(config) + ``` +""" + from argparse import ArgumentParser from pytoml import load from sys import argv @@ -14,6 +27,10 @@ config = { 'frontend_path': None, 'root_url': 'http://localhost:8000/', } +""" Default configuration. + +The default configuration gets overwritten by a configuration file if one exists. +""" if argv[0].endswith('kibicara'): parser = ArgumentParser() diff --git a/kibicara/email.py b/kibicara/email.py index 8ab2d5d..036a219 100644 --- a/kibicara/email.py +++ b/kibicara/email.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: 0BSD +""" E-Mail handling. """ + from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from logging import getLogger @@ -14,6 +16,22 @@ logger = getLogger(__name__) def send_email(to, subject, sender='kibicara', body=''): + """ E-Mail sender. + + Sends an E-Mail to a specified recipient with a body + + Example: + ``` + from kibicara import email + email.send_email('abc@de.fg', 'My Email subject', body='Hi this is a mail body.') + ``` + + Args: + to (str): Recipients' e-mail address + subject (str): The subject of the e-mail + sender (str): optional, Sender of the e-mail + body (str): The body of the e-mail + """ msg = MIMEMultipart() msg['From'] = 'Kibicara <%s@%s>' % (sender, getfqdn()) msg['To'] = to diff --git a/kibicara/kibicara.py b/kibicara/kibicara.py index 2734530..904eb53 100644 --- a/kibicara/kibicara.py +++ b/kibicara/kibicara.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: 0BSD +""" Entrypoint of Kibicara. """ + from asyncio import run as asyncio_run from fastapi import FastAPI from fastapi.staticfiles import StaticFiles @@ -19,17 +21,23 @@ logger = getLogger(__name__) class Main: - def __init__(self): - asyncio_run(self.run()) + """ Entrypoint for Kibicara. - async def run(self): + Initializes the platform bots and starts the hypercorn webserver serving the + Kibicara application and the specified frontend on port 8000. + """ + + def __init__(self): + asyncio_run(self.__run()) + + async def __run(self): basicConfig(level=DEBUG, format="%(asctime)s %(name)s %(message)s") getLogger('aiosqlite').setLevel(WARNING) Mapping.create_all() await Spawner.init_all() - await self._start_webserver() + await self.__start_webserver() - async def _start_webserver(self): + async def __start_webserver(self): class SinglePageApplication(StaticFiles): async def get_response(self, path, scope): response = await super().get_response(path, scope) diff --git a/kibicara/model.py b/kibicara/model.py index afe39ae..103a803 100644 --- a/kibicara/model.py +++ b/kibicara/model.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: 0BSD +""" ORM Models for core. """ + from databases import Database from kibicara.config import config from ormantic import Integer, ForeignKey, Model, Text diff --git a/kibicara/platformapi.py b/kibicara/platformapi.py index 1055168..78bed7b 100644 --- a/kibicara/platformapi.py +++ b/kibicara/platformapi.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: 0BSD +""" API classes for implementing bots for platforms. """ + from asyncio import create_task, Queue from kibicara.model import BadWord, Trigger from logging import getLogger @@ -13,47 +15,116 @@ logger = getLogger(__name__) class Message: + """The Message object that is send through the censor. + + Examples: + ``` + message = Message('Message sent by a user from platform xyz', xyz_message_id=123) + ``` + + Args: + text (str): The message text + **kwargs (object, optional): Other platform-specific data. + + Attributes: + text (str): The message text + **kwargs (object, optional): Other platform-specific data. + """ + def __init__(self, text, **kwargs): self.text = text self.__dict__.update(kwargs) class Censor: - instances = {} + """ The superclass for a platform bot. + + The censor is the superclass for every platform bot. It distributes a message to all + other bots from the same hood if it passes the message filter. It provides methods + to start and stop the bot and an overwritable stub for a starting routine. + + Examples: + ``` + class XYZPlatform(Censor): + def __init__(self, xyz_model): + super().__init__(xyz_model.hood) + ... + async def run(self): + await gather(self.poll(), self.push()) + ... + async def poll(self): + while True: + # XXX get text message from platform xyz + await self.publish(Message(text)) + ... + async def push(self): + while True: + message = await self.receive() + # XXX send message.text to platform xyz + ``` + + Args: + hood (Hood): A Hood Model object + + Attributes: + hood (Hood): A Hood Model object + """ + + __instances = {} def __init__(self, hood): self.hood = hood - self.inbox = Queue() - self.task = None - self.hood_censors = self.instances.setdefault(hood.id, []) - self.hood_censors.append(self) + self._inbox = Queue() + self.__task = None + self.__hood_censors = self.__instances.setdefault(hood.id, []) + self.__hood_censors.append(self) def start(self): - if self.task is None: - self.task = create_task(self.__run()) + """ Start the bot. + + Note: This will be called by a spawner, a platform bot should not call this. + """ + if self.__task is None: + self.__task = create_task(self.__run()) def stop(self): - if self.task is not None: - self.task.cancel() - self.task = None + """ Stop the bot. + + Note: This will be called by a spawner, a platform bot should not call this. + """ + if self.__task is not None: + self.__task.cancel() + self.__task = None async def __run(self): await self.hood.load() - self.task.set_name('%s %s' % (self.__class__.__name__, self.hood.name)) + self.__task.set_name('%s %s' % (self.__class__.__name__, self.hood.name)) await self.run() - # override this in derived class async def run(self): + """ Entry point for a bot. + + Note: Override this in the derived bot class. + """ pass async def publish(self, message): + """ Distribute a message to the bots in a hood. + + Args: + message (Message): Message to distribute + """ if not await self.__is_appropriate(message): return - for censor in self.hood_censors: - await censor.inbox.put(message) + for censor in self.__hood_censors: + await censor._inbox.put(message) async def receive(self): - return await self.inbox.get() + """ Receive a message. + + Returns (Message): Received message + """ + return await self._inbox.get() async def __is_appropriate(self, message): for badword in await BadWord.objects.filter(hood=self.hood).all(): @@ -69,17 +140,40 @@ class Censor: class Spawner: - instances = [] + """ Spawns a bot with a specific bot model. + + Examples: + ``` + class XYZPlatform(Censor): + # bot class + + class XYZ(Model): + # bot model + + spawner = Spawner(XYZ, XYZPlatform) + ``` + + Args: + ORMClass (ORM Model subclass): A Bot Model object + BotClass (Censor subclass): A Bot Class object + + Attributes: + ORMClass (ORM Model subclass): A Hood Model object + BotClass (Censor subclass): A Bot Class object + """ + + __instances = [] def __init__(self, ORMClass, BotClass): self.ORMClass = ORMClass self.BotClass = BotClass - self.bots = {} - self.instances.append(self) + self.__bots = {} + self.__instances.append(self) @classmethod async def init_all(cls): - for spawner in cls.instances: + """ Instantiate and start a bot for every row in the corresponding ORM model. """ + for spawner in cls.__instances: await spawner._init() async def _init(self): @@ -87,13 +181,34 @@ class Spawner: self.start(item) def start(self, item): - bot = self.bots.setdefault(item.pk, self.BotClass(item)) + """ Instantiate and start a bot with the provided ORM object. + + Example: + ``` + xyz = await XYZ.objects.create(hood=hood, **values.__dict__) + spawner.start(xyz) + ``` + + Args: + item (ORM Model object): Argument to the bot constructor + """ + bot = self.__bots.setdefault(item.pk, self.BotClass(item)) bot.start() def stop(self, item): - bot = self.bots.pop(item.pk, None) + """ Stop and delete a bot. + + Args: + item (ORM Model object): ORM object corresponding to bot. + """ + bot = self.__bots.pop(item.pk, None) if bot is not None: bot.stop() def get(self, item): - return self.bots.get(item.pk) + """ Get a running bot. + + Args: + item (ORM Model object): ORM object corresponding to bot. + """ + return self.__bots.get(item.pk) diff --git a/kibicara/webapi/__init__.py b/kibicara/webapi/__init__.py index cdeef5b..0b5cd57 100644 --- a/kibicara/webapi/__init__.py +++ b/kibicara/webapi/__init__.py @@ -3,6 +3,12 @@ # # SPDX-License-Identifier: 0BSD +""" Routing definitions for the REST API. + +A platform bot shall add its API router in this `__init__.py` +file to get included into the main application. +""" + from fastapi import APIRouter from kibicara.platforms.test.webapi import router as test_router from kibicara.platforms.telegram.webapi import router as telegram_router diff --git a/kibicara/webapi/admin.py b/kibicara/webapi/admin.py index 490fd34..0a1adda 100644 --- a/kibicara/webapi/admin.py +++ b/kibicara/webapi/admin.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: 0BSD +""" REST API endpoints for hood admins. """ + from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from kibicara import email @@ -71,8 +73,14 @@ router = APIRouter() @router.post('/register/', status_code=status.HTTP_202_ACCEPTED) async def admin_register(values: BodyAdmin): + """ Sends an email with a confirmation link. + + - **email**: E-Mail Address of new hood admin + - **password**: Password of new hood admin + """ register_token = to_token(**values.__dict__) logger.debug(f'register_token={register_token}') + # TODO implement check to see if email already is in database try: email.send_email( to=values.email, @@ -88,6 +96,10 @@ async def admin_register(values: BodyAdmin): @router.post('/confirm/{register_token}') async def admin_confirm(register_token: str): + """ Registration confirmation and account creation. + + - **register_token**: Registration token received in email from /register + """ try: values = from_token(register_token) passhash = argon2.hash(values['password']) @@ -99,6 +111,11 @@ async def admin_confirm(register_token: str): @router.post('/login/') async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()): + """ Get an access token. + + - **username**: Email of a registered hood admin + - **password**: Password of a registered hood admin + """ try: await get_auth(form_data.username, form_data.password) except ValueError: @@ -112,6 +129,7 @@ async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()): @router.get('/hoods/') async def admin_hood_read_all(admin=Depends(get_admin)): + """ Get a list of all hoods of a given admin. """ return ( await AdminHoodRelation.objects.select_related('hood').filter(admin=admin).all() ) diff --git a/kibicara/webapi/hoods/__init__.py b/kibicara/webapi/hoods/__init__.py index fd5d7c8..29e57e1 100644 --- a/kibicara/webapi/hoods/__init__.py +++ b/kibicara/webapi/hoods/__init__.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: 0BSD +""" REST API Endpoints for managing hoods. """ + from fastapi import APIRouter, Depends, HTTPException, Response, status from kibicara.model import AdminHoodRelation, Hood from kibicara.webapi.admin import get_admin @@ -38,11 +40,17 @@ router = APIRouter() @router.get('/') async def hood_read_all(): + """ Get all existing hoods. """ return await Hood.objects.all() @router.post('/', status_code=status.HTTP_201_CREATED) async def hood_create(values: BodyHood, response: Response, admin=Depends(get_admin)): + """ Creates a hood. + + - **name**: Name of the hood + - **landingpage**: Markdown formatted description of the hood + """ try: hood = await Hood.objects.create(**values.__dict__) await AdminHoodRelation.objects.create(admin=admin.id, hood=hood.id) @@ -54,16 +62,23 @@ async def hood_create(values: BodyHood, response: Response, admin=Depends(get_ad @router.get('/{hood_id}') async def hood_read(hood=Depends(get_hood)): + """ Get hood with id **hood_id**. """ return hood @router.put('/{hood_id}', status_code=status.HTTP_204_NO_CONTENT) async def hood_update(values: BodyHood, hood=Depends(get_hood)): + """ Updates hood with id **hood_id**. + + - **name**: New name of the hood + - **landingpage**: New Markdown formatted description of the hood + """ await hood.update(**values.__dict__) @router.delete('/{hood_id}', status_code=status.HTTP_204_NO_CONTENT) async def hood_delete(hood=Depends(get_hood)): + """ Deletes hood with id **hood_id**. """ for relation in await AdminHoodRelation.objects.filter(hood=hood).all(): await relation.delete() await hood.delete() diff --git a/kibicara/webapi/hoods/badwords.py b/kibicara/webapi/hoods/badwords.py index 838b458..2ac4750 100644 --- a/kibicara/webapi/hoods/badwords.py +++ b/kibicara/webapi/hoods/badwords.py @@ -3,6 +3,13 @@ # # SPDX-License-Identifier: 0BSD +""" REST API endpoints for managing badwords. + +Provides API endpoints for adding, removing and reading regular expressions that block a +received message to be dropped by a censor. This provides a message filter customizable +by the hood admins. +""" + from fastapi import APIRouter, Depends, HTTPException, Response, status from kibicara.model import BadWord from kibicara.webapi.hoods import get_hood @@ -28,6 +35,7 @@ router = APIRouter() @router.get('/') async def badword_read_all(hood=Depends(get_hood)): + """ Get all badwords of hood with id **hood_id**. """ return await BadWord.objects.filter(hood=hood).all() @@ -35,6 +43,10 @@ async def badword_read_all(hood=Depends(get_hood)): async def badword_create( values: BodyBadWord, response: Response, hood=Depends(get_hood) ): + """ Creates a new badword for hood with id **hood_id**. + + - **pattern**: Regular expression which is used to match a badword. + """ try: regex_compile(values.pattern) badword = await BadWord.objects.create(hood=hood, **values.__dict__) @@ -48,14 +60,20 @@ async def badword_create( @router.get('/{badword_id}') async def badword_read(badword=Depends(get_badword)): + """ Reads badword with id **badword_id** for hood with id **hood_id**. """ return badword @router.put('/{badword_id}', status_code=status.HTTP_204_NO_CONTENT) async def badword_update(values: BodyBadWord, badword=Depends(get_badword)): + """ Updates badword with id **badword_id** for hood with id **hood_id**. + + - **pattern**: Regular expression which is used to match a badword + """ await badword.update(**values.__dict__) @router.delete('/{badword_id}', status_code=status.HTTP_204_NO_CONTENT) async def badword_delete(badword=Depends(get_badword)): + """ Deletes badword with id **badword_id** for hood with id **hood_id**. """ await badword.delete() diff --git a/kibicara/webapi/hoods/triggers.py b/kibicara/webapi/hoods/triggers.py index efcbb79..69d8199 100644 --- a/kibicara/webapi/hoods/triggers.py +++ b/kibicara/webapi/hoods/triggers.py @@ -3,6 +3,14 @@ # # SPDX-License-Identifier: 0BSD +""" REST API endpoints for managing triggers. + +Provides API endpoints for adding, removing and reading regular expressions that allow a +message to be passed through by a censor. A published message must match one of these +regular expressions otherwise it gets dropped by the censor. This provides a message +filter customizable by the hood admins. +""" + from fastapi import APIRouter, Depends, HTTPException, Response, status from kibicara.model import Trigger from kibicara.webapi.hoods import get_hood @@ -28,6 +36,7 @@ router = APIRouter() @router.get('/') async def trigger_read_all(hood=Depends(get_hood)): + """ Get all triggers of hood with id **hood_id**. """ return await Trigger.objects.filter(hood=hood).all() @@ -35,6 +44,10 @@ async def trigger_read_all(hood=Depends(get_hood)): async def trigger_create( values: BodyTrigger, response: Response, hood=Depends(get_hood) ): + """ Creates a new trigger for hood with id **hood_id**. + + - **pattern**: Regular expression which is used to match a trigger. + """ try: regex_compile(values.pattern) trigger = await Trigger.objects.create(hood=hood, **values.__dict__) @@ -48,14 +61,20 @@ async def trigger_create( @router.get('/{trigger_id}') async def trigger_read(trigger=Depends(get_trigger)): + """ Reads trigger with id **trigger_id** for hood with id **hood_id**. """ return trigger @router.put('/{trigger_id}', status_code=status.HTTP_204_NO_CONTENT) async def trigger_update(values: BodyTrigger, trigger=Depends(get_trigger)): + """ Updates trigger with id **trigger_id** for hood with id **hood_id**. + + - **pattern**: Regular expression which is used to match a trigger + """ await trigger.update(**values.__dict__) @router.delete('/{trigger_id}', status_code=status.HTTP_204_NO_CONTENT) async def trigger_delete(trigger=Depends(get_trigger)): + """ Deletes trigger with id **trigger_id** for hood with id **hood_id**. """ await trigger.delete()