From a2f2aa6855f413dcfa541c3fa569a0a8cfb1ad95 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Mon, 25 Nov 2019 13:31:28 +0100 Subject: [PATCH] le epic commit --- poetry.lock | 11 + royalnet/__main__.py | 5 +- royalnet/backpack/tables/__init__.py | 4 +- royalnet/commands/command.py | 3 - royalnet/commands/commandinterface.py | 12 +- royalnet/commands/event.py | 13 +- royalnet/herald/config.py | 5 +- royalnet/serf/discord/discordserf.py | 35 +-- royalnet/serf/serf.py | 299 ++++++++++++++----------- royalnet/serf/telegram/telegramserf.py | 56 +++-- sample_config.toml | 12 +- 11 files changed, 257 insertions(+), 198 deletions(-) diff --git a/poetry.lock b/poetry.lock index ce9e234b..83b84bc9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1202,13 +1202,24 @@ websockets = [ {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"}, {file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"}, {file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"}, + {file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"}, + {file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"}, {file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"}, {file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"}, {file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"}, {file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"}, {file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"}, + {file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"}, + {file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"}, {file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"}, {file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"}, + {file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"}, + {file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"}, + {file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"}, + {file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"}, + {file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"}, + {file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"}, + {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"}, {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"}, ] yarl = [ diff --git a/royalnet/__main__.py b/royalnet/__main__.py index 19a7bf3f..14d23ed1 100644 --- a/royalnet/__main__.py +++ b/royalnet/__main__.py @@ -12,6 +12,9 @@ except ImportError: coloredlogs = None +log = getLogger(__name__) + + @click.command() @click.option("-c", "--config-filename", default="./config.toml", type=str, help="The filename of the Royalnet configuration file.") @@ -31,7 +34,7 @@ def run(config_filename: str): stream_handler.formatter = Formatter("{asctime}\t| {processName}\t| {name}\t| {message}", style="{") royalnet_log.addHandler(stream_handler) - royalnet_log.info("Logging: ready") + log.info("Logging: ready") herald_process: typing.Optional[multiprocessing.Process] = None herald_config = r.herald.Config(name="", diff --git a/royalnet/backpack/tables/__init__.py b/royalnet/backpack/tables/__init__.py index cc96674c..ca4f3429 100644 --- a/royalnet/backpack/tables/__init__.py +++ b/royalnet/backpack/tables/__init__.py @@ -4,11 +4,11 @@ from .telegram import Telegram from .discord import Discord # Enter the tables of your Pack here! -available_tables = [ +available_tables = { User, Telegram, Discord -] +} # Don't change this, it should automatically generate __all__ __all__ = [table.__name__ for table in available_tables] diff --git a/royalnet/commands/command.py b/royalnet/commands/command.py index ac6a5dbf..08758d32 100644 --- a/royalnet/commands/command.py +++ b/royalnet/commands/command.py @@ -24,9 +24,6 @@ class Command: """The syntax of the command, to be displayed when a :py:exc:`InvalidInputError` is raised, in the format ``(required_arg) [optional_arg]``.""" - tables: typing.Set = set() - """A set of :mod:`royalnet.alchemy` tables that must exist for this command to work.""" - def __init__(self, interface: CommandInterface): self.interface = interface diff --git a/royalnet/commands/commandinterface.py b/royalnet/commands/commandinterface.py index ed31ddeb..2590a09d 100644 --- a/royalnet/commands/commandinterface.py +++ b/royalnet/commands/commandinterface.py @@ -1,7 +1,8 @@ -from typing import Optional, TYPE_CHECKING, Awaitable, Any, Callable +from typing import * from asyncio import AbstractEventLoop from .errors import UnsupportedError if TYPE_CHECKING: + from .event import Event from .command import Command from ..alchemy import Alchemy from ..serf import Serf @@ -36,8 +37,13 @@ class CommandInterface: """A shortcut for :attr:`serf.loop`.""" return self.serf.loop - def __init__(self): - self.command: Optional[Command] = None # Will be bound after the command has been created + def __init__(self, cfg: Dict[str, Any]): + self.cfg: Dict[str, Any] = cfg + """The config section for the pack of the command.""" + + # Will be bound after the command/event has been created + self.command: Optional[Command] = None + self.event: Optional[Event] = None async def call_herald_event(self, destination: str, event_name: str, **kwargs) -> dict: """Call an event function on a different :class:`Serf`. diff --git a/royalnet/commands/event.py b/royalnet/commands/event.py index a923f612..0e9402cc 100644 --- a/royalnet/commands/event.py +++ b/royalnet/commands/event.py @@ -1,3 +1,4 @@ +from .commandinterface import CommandInterface from typing import TYPE_CHECKING if TYPE_CHECKING: from serf import Serf @@ -9,22 +10,20 @@ class Event: name = NotImplemented """The event_name that will trigger this event.""" - tables: set = set() - """A set of :mod:`royalnet.alchemy` tables that must exist for this event to work.""" - - def __init__(self, serf: "Serf"): + def __init__(self, interface: CommandInterface): """Bind the event to a :class:`~royalnet.serf.Serf`.""" - self.serf: "Serf" = serf + self.interface: CommandInterface = interface + """The :class:`CommandInterface` available to this :class:`Event`.""" @property def alchemy(self): """A shortcut for :attr:`.serf.alchemy`.""" - return self.serf.alchemy + return self.interface.serf.alchemy @property def loop(self): """A shortcut for :attr:`.serf.loop`""" - return self.serf.loop + return self.interface.serf.loop async def run(self, **kwargs): raise NotImplementedError() diff --git a/royalnet/herald/config.py b/royalnet/herald/config.py index 52095dd8..ac4fee9e 100644 --- a/royalnet/herald/config.py +++ b/royalnet/herald/config.py @@ -8,7 +8,10 @@ class Config: port: int, secret: str, secure: bool = False, - path: str = "/"): + path: str = "/", + *, + enabled: ... = ..., # Ignored, but useful to allow creating a config from the config dict + ): if ":" in name: raise ValueError("Herald names cannot contain colons (:)") self.name = name diff --git a/royalnet/serf/discord/discordserf.py b/royalnet/serf/discord/discordserf.py index 232fc7d9..a6f14523 100644 --- a/royalnet/serf/discord/discordserf.py +++ b/royalnet/serf/discord/discordserf.py @@ -1,7 +1,8 @@ import asyncio import logging import warnings -from typing import Type, Optional, List, Union, Dict +from typing import * +import royalnet.backpack as rb from royalnet.commands import * from royalnet.utils import asyncify from royalnet.serf import Serf @@ -33,21 +34,25 @@ class DiscordSerf(Serf): """A :class:`Serf` that connects to `Discord `_ as a bot.""" interface_name = "discord" - def __init__(self, *, - token: str, - alchemy_config: Optional[AlchemyConfig] = None, - commands: List[Type[Command]] = None, - events: List[Type[Event]] = None, - herald_config: Optional[HeraldConfig] = None): + _identity_table = rb.tables.Discord + _identity_column = "discord_id" + + def __init__(self, + alchemy_cfg: Dict[str, Any], + herald_cfg: Dict[str, Any], + sentry_cfg: Dict[str, Any], + packs_cfg: Dict[str, Any], + serf_cfg: Dict[str, Any]): if discord is None: raise ImportError("'discord' extra is not installed") - super().__init__(alchemy_config=alchemy_config, - commands=commands, - events=events, - herald_config=herald_config) + super().__init__(alchemy_cfg=alchemy_cfg, + herald_cfg=herald_cfg, + sentry_cfg=sentry_cfg, + packs_cfg=packs_cfg, + serf_cfg=serf_cfg) - self.token = token + self.token = serf_cfg["token"] """The Discord bot token.""" self.Client = self.client_factory() @@ -86,10 +91,10 @@ class DiscordSerf(Serf): async def get_author(data, error_if_none=False): user: "discord.Member" = data.message.author - query = data.session.query(self._master_table) - for link in self._identity_chain: + query = data.session.query(self.master_table) + for link in self.identity_chain: query = query.join(link.mapper.class_) - query = query.filter(self._identity_column == user.id) + query = query.filter(self.identity_column == user.id) result = await asyncify(query.one_or_none) if result is None and error_if_none: raise CommandError("You must be registered to use this command.") diff --git a/royalnet/serf/serf.py b/royalnet/serf/serf.py index e443ced5..0e86b433 100644 --- a/royalnet/serf/serf.py +++ b/royalnet/serf/serf.py @@ -1,30 +1,17 @@ import logging import sys import traceback -from asyncio import Task, AbstractEventLoop, get_event_loop -from typing import Type, Optional, Awaitable, Dict, List, Any, Callable, Union, Set +import importlib +import asyncio as aio +from typing import * + from sqlalchemy.schema import Table -from royalnet import __version__ as version + +from royalnet import __version__ from royalnet.commands import * -from .alchemyconfig import AlchemyConfig - -try: - from royalnet.alchemy import Alchemy, table_dfs -except ImportError: - Alchemy = None - table_dfs = None - -try: - from royalnet.herald import Response, ResponseSuccess, Broadcast, ResponseFailure, Request, Link - from royalnet.herald import Config as HeraldConfig -except ImportError: - Response = None - ResponseSuccess = None - Broadcast = None - ResponseFailure = None - Request = None - Link = None - HeraldConfig = None +import royalnet.alchemy as ra +import royalnet.backpack as rb +import royalnet.herald as rh try: import sentry_sdk @@ -50,34 +37,91 @@ class Serf: Discord).""" interface_name = NotImplemented - def __init__(self, *, - alchemy_config: Optional[AlchemyConfig] = None, - commands: List[Type[Command]] = None, - events: List[Type[Event]] = None, - herald_config: Optional[HeraldConfig] = None, - sentry_dsn: Optional[str] = None): - self.alchemy: Optional[Alchemy] = None - """The :class:`Alchemy` object connecting this Serf to the database.""" + _master_table: type = rb.tables.User + _identity_table: type = NotImplemented + _identity_column: str = NotImplemented - self._master_table: Optional[Table] = None + def __init__(self, + alchemy_cfg: Dict[str, Any], + herald_cfg: Dict[str, Any], + sentry_cfg: Dict[str, Any], + packs_cfg: Dict[str, Any], + serf_cfg: Dict[str, Any]): + + # Import packs + pack_names = packs_cfg["active"] + packs = {} + for pack_name in pack_names: + log.debug(f"Importing pack: {pack_name}") + try: + packs[pack_name] = importlib.import_module(pack_name) + except ImportError as e: + log.error(f"Error during the import of {pack_name}: {e}") + # pack_commands = [] + # try: + # pack_commands = pack.available_commands + # except AttributeError: + # log.warning(f"No commands in pack: {pack_name}") + # else: + # log.debug(f"Imported: {len(pack_commands)} commands") + # commands = [*commands, *pack_commands] + # pack_events = [] + # try: + # pack_events = pack.available_events + # except AttributeError: + # log.warning(f"No events in pack: {pack_name}") + # else: + # log.debug(f"Imported: {len(pack_events)} events") + # events = [*events, *pack_events] + # pack_tables = [] + # try: + # pack_tables = pack.available_events + # except AttributeError: + # log.warning(f"No tables in pack: {pack_name}") + # else: + # log.debug(f"Imported: {len(pack_tables)} tables") + # tables = [*tables, *pack_tables] + log.info(f"Packs: {len(packs)} imported") + + self.alchemy: Optional[ra.Alchemy] = None + """The :class:`Alchemy` object connecting this Serf to a database.""" + + self.master_table: Optional[Table] = None """The central table listing all users. It usually is :class:`User`.""" - self._identity_table: Optional[Table] = None + self.identity_table: Optional[Table] = None """The identity table containing the interface data (such as the Telegram user data) and that is in a many-to-one relationship with the master table.""" # TODO: I'm not sure what this is either - self._identity_column: Optional[str] = None + self.identity_column: Optional[str] = None - if Alchemy is None: + # Find all tables + tables = set() + for pack in packs.values(): + try: + tables = tables.union(pack.available_tables) + except AttributeError: + log.warning(f"Pack `{pack}` does not have the `available_tables` attribute.") + continue + + if ra.Alchemy is None: log.info("Alchemy: not installed") - elif alchemy_config is None: + elif not alchemy_cfg["enabled"]: log.info("Alchemy: disabled") else: - tables = self.find_tables(alchemy_config, commands) - self.init_alchemy(alchemy_config, tables) + self.init_alchemy(alchemy_cfg["database_url"], tables) log.info(f"Alchemy: {self.alchemy}") + self.herald: Optional[rh.Link] = None + """The :class:`Link` object connecting the Serf to the rest of the herald network.""" + + self.herald_task: Optional[aio.Task] = None + """A reference to the :class:`asyncio.Task` that runs the :class:`Link`.""" + + self.events: Dict[str, Event] = {} + """A dictionary containing all :class:`Event` that can be handled by this :class:`Serf`.""" + self.Interface: Type[CommandInterface] = self.interface_factory() """The :class:`CommandInterface` class of this Serf.""" @@ -87,72 +131,59 @@ class Serf: self.commands: Dict[str, Command] = {} """The :class:`dict` connecting each command name to its :class:`Command` object.""" - if commands is None: - commands = [] - self.register_commands(commands) - log.info(f"Commands: total {len(self.commands)}") + for pack_name in packs: + pack = packs[pack_name] + pack_cfg = packs_cfg.get(pack_name, default={}) + try: + events = pack.available_events + except AttributeError: + log.warning(f"Pack `{pack}` does not have the `available_events` attribute.") + else: + self.register_events(events, pack_cfg) + try: + commands = pack.available_commands + except AttributeError: + log.warning(f"Pack `{pack}` does not have the `available_commands` attribute.") + else: + self.register_commands(commands, pack_cfg) + log.info(f"Events: {len(self.commands)} events") + log.info(f"Commands: {len(self.commands)} commands") - self.herald: Optional[Link] = None - """The :class:`Link` object connecting the Serf to the rest of the herald network.""" - - self.herald_task: Optional[Task] = None - """A reference to the :class:`asyncio.Task` that runs the :class:`Link`.""" - - self.events: Dict[str, Event] = {} - """A dictionary containing all :class:`Event` that can be handled by this :class:`Serf`.""" - - if Link is None: + if rh.Link is None: log.info("Herald: not installed") - elif herald_config is None: + elif not herald_cfg["enabled"]: log.info("Herald: disabled") else: - self.init_herald(herald_config, events) - log.info(f"Herald: {len(self.events)} events bound") + self.init_herald(herald_cfg) + log.info(f"Herald: enabled") - self.loop: Optional[AbstractEventLoop] = None + self.loop: Optional[aio.AbstractEventLoop] = None """The event loop this Serf is running on.""" - self.sentry_dsn: Optional[str] = sentry_dsn + self.sentry_dsn: Optional[str] = sentry_cfg["dsn"] if sentry_cfg["enabled"] else None """The Sentry DSN / Token. If :const:`None`, Sentry is disabled.""" - @staticmethod - def find_tables(alchemy_config: AlchemyConfig, commands: List[Type[Command]]) -> Set[type]: - """Find the :class:`Table`s required by the Serf. - - Warning: - This function will return a wrong result if there are tables between the master table and the identity table - that aren't included by a command. - - Returns: - A :class:`list` of :class:`Table`s.""" - # FIXME: breaks if there are nonincluded tables between master and identity. - tables = {alchemy_config.master_table, alchemy_config.identity_table} - for command in commands: - tables = tables.union(command.tables) - return tables - - def init_alchemy(self, alchemy_config: AlchemyConfig, tables: Set[type]) -> None: + def init_alchemy(self, alchemy_cfg: Dict[str, Any], tables: Set[type]) -> None: """Create and initialize the :class:`Alchemy` with the required tables, and find the link between the master table and the identity table.""" - self.alchemy = Alchemy(alchemy_config.database_url, tables) - self._master_table = self.alchemy.get(alchemy_config.master_table) - self._identity_table = self.alchemy.get(alchemy_config.identity_table) + self.alchemy = ra.Alchemy(alchemy_cfg["database_url"], tables) + self.master_table = self.alchemy.get(self._master_table) + self.identity_table = self.alchemy.get(self._identity_table) # This is fine, as Pycharm doesn't know that identity_table is a class and not an object # noinspection PyArgumentList - self._identity_column = self._identity_table.__getattribute__(self._identity_table, - alchemy_config.identity_column) + self.identity_column = self.identity_table.__getattribute__(self.identity_table, self._identity_column) @property - def _identity_chain(self) -> tuple: + def identity_chain(self) -> tuple: """Find a relationship path starting from the master table and ending at the identity table, and return it.""" - return table_dfs(self._master_table, self._identity_table) + return ra.table_dfs(self.master_table, self.identity_table) def interface_factory(self) -> Type[CommandInterface]: """Create the :class:`CommandInterface` class for the Serf.""" # noinspection PyMethodParameters class GenericInterface(CommandInterface): - alchemy: Alchemy = self.alchemy + alchemy: ra.Alchemy = self.alchemy serf: "Serf" = self async def call_herald_event(ci, destination: str, event_name: str, **kwargs) -> Dict: @@ -160,9 +191,9 @@ class Serf: :class:`royalherald.Response`.""" if self.herald is None: raise UnsupportedError("`royalherald` is not enabled on this bot.") - request: Request = Request(handler=event_name, data=kwargs) - response: Response = await self.herald.request(destination=destination, request=request) - if isinstance(response, ResponseFailure): + request: rh.Request = rh.Request(handler=event_name, data=kwargs) + response: rh.Response = await self.herald.request(destination=destination, request=request) + if isinstance(response, rh.ResponseFailure): if response.name == "no_event": raise CommandError(f"There is no event named {event_name} in {destination}.") elif response.name == "exception_in_event": @@ -182,7 +213,7 @@ class Serf: else: raise TypeError(f"Herald action call returned invalid error:\n" f"[p]{response}[/p]") - elif isinstance(response, ResponseSuccess): + elif isinstance(response, rh.ResponseSuccess): return response.data else: raise TypeError(f"Other Herald Link returned unknown response:\n" @@ -194,35 +225,29 @@ class Serf: """Create the :class:`CommandData` for the Serf.""" raise NotImplementedError() - def register_commands(self, commands: List[Type[Command]]) -> None: - """Initialize and register all commands passed as argument. - - If called again during the execution of the bot, all current commands will be replaced with the new ones. - - Warning: - Hot-replacing commands was never tested and probably doesn't work.""" - log.info(f"Registering {len(commands)} commands...") + def register_commands(self, commands: List[Type[Command]], pack_cfg: Dict[str, Any]) -> None: + """Initialize and register all commands passed as argument.""" # Instantiate the Commands for SelectedCommand in commands: - # Warn if the command would be overriding something - if f"{self.Interface.prefix}{SelectedCommand.name}" in self.commands: - log.warning(f"Overriding (already defined): " - f"{SelectedCommand.__qualname__} -> {self.Interface.prefix}{SelectedCommand.name}") - else: - log.debug(f"Registering: " - f"{SelectedCommand.__qualname__} -> {self.Interface.prefix}{SelectedCommand.name}") # Create a new interface - interface = self.Interface() + interface = self.Interface(cfg=pack_cfg) # Try to instantiate the command try: command = SelectedCommand(interface) except Exception as e: log.error(f"Skipping: " f"{SelectedCommand.__qualname__} - {e.__class__.__qualname__} in the initialization.") - sentry_sdk.capture_exception(e) + self.sentry_exc(e) continue # Link the interface to the command interface.command = command + # Warn if the command would be overriding something + if f"{self.Interface.prefix}{SelectedCommand.name}" in self.commands: + log.info(f"Overriding (already defined): " + f"{SelectedCommand.__qualname__} -> {self.Interface.prefix}{SelectedCommand.name}") + else: + log.debug(f"Registering: " + f"{SelectedCommand.__qualname__} -> {self.Interface.prefix}{SelectedCommand.name}") # Register the command in the commands dict self.commands[f"{interface.prefix}{SelectedCommand.name}"] = command # Register aliases, but don't override anything @@ -232,52 +257,65 @@ class Serf: self.commands[f"{interface.prefix}{alias}"] = \ self.commands[f"{interface.prefix}{SelectedCommand.name}"] else: - log.warning(f"Ignoring (already defined): {SelectedCommand.__qualname__} -> {interface.prefix}{alias}") + log.warning( + f"Ignoring (already defined): {SelectedCommand.__qualname__} -> {interface.prefix}{alias}") - def init_herald(self, config: HeraldConfig, events: List[Type[Event]]): - """Create a :py:class:`Link`, and run it as a :py:class:`asyncio.Task`.""" - self.herald: Link = Link(config, self.network_handler) - log.debug(f"Binding events...") + def init_herald(self, herald_cfg: Dict[str, Any]): + """Create a :class:`Link` and bind :class:`Event`.""" + herald_cfg["name"] = self.interface_name + self.herald: rh.Link = rh.Link(rh.Config(**herald_cfg), self.network_handler) + + def register_events(self, events: List[Type[Event]], pack_cfg: Dict[str, Any]): for SelectedEvent in events: - log.debug(f"Binding event: {SelectedEvent.name}.") - self.events[SelectedEvent.name] = SelectedEvent(self) + # Create a new interface + interface = self.Interface(cfg=pack_cfg) + # Initialize the event + try: + event = SelectedEvent(interface) + except Exception as e: + log.error(f"Skipping: " + f"{SelectedEvent.__qualname__} - {e.__class__.__qualname__} in the initialization.") + self.sentry_exc(e) + continue + # Register the event + if SelectedEvent.name in self.events: + log.warning(f"Overriding (already defined): {SelectedEvent.__qualname__} -> {SelectedEvent.name}") + else: + log.debug(f"Registering: {SelectedEvent.__qualname__} -> {SelectedEvent.name}") + self.events[SelectedEvent.name] = event - async def network_handler(self, message: Union[Request, Broadcast]) -> Response: + async def network_handler(self, message: Union[rh.Request, rh.Broadcast]) -> rh.Response: try: event: Event = self.events[message.handler] except KeyError: log.warning(f"No event for '{message.handler}'") - return ResponseFailure("no_event", f"This serf does not have any event for {message.handler}.") + return rh.ResponseFailure("no_event", f"This serf does not have any event for {message.handler}.") log.debug(f"Event called: {event.name}") - if isinstance(message, Request): + if isinstance(message, rh.Request): try: response_data = await event.run(**message.data) - return ResponseSuccess(data=response_data) + return rh.ResponseSuccess(data=response_data) except Exception as e: self.sentry_exc(e) - return ResponseFailure("exception_in_event", - f"An exception was raised in the event for '{message.handler}'.", - extra_info={ - "type": e.__class__.__qualname__, - "message": str(e) - }) - elif isinstance(message, Broadcast): + return rh.ResponseFailure("exception_in_event", + f"An exception was raised in the event for '{message.handler}'.", + extra_info={ + "type": e.__class__.__qualname__, + "message": str(e) + }) + elif isinstance(message, rh.Broadcast): await event.run(**message.data) @staticmethod def init_sentry(dsn): - # noinspection PyUnreachableCode - if __debug__: - release = f"royalnet" - else: - release = f"royalnet=={version}" log.debug("Initializing Sentry...") + release = f"royalnet@{__version__}" sentry_sdk.init(dsn, integrations=[AioHttpIntegration(), SqlalchemyIntegration(), LoggingIntegration(event_level=None)], release=release) - log.info(f"Sentry: enabled (release {release})") + log.info(f"Sentry: {release}") # noinspection PyUnreachableCode @staticmethod @@ -343,13 +381,12 @@ class Serf: if sentry_sdk is None: log.info("Sentry: not installed") + elif serf.sentry_dsn is None: + log.info("Sentry: disabled") else: - if serf.sentry_dsn is None: - log.info("Sentry: disabled") - else: - serf.init_sentry(serf.sentry_dsn) + serf.init_sentry(serf.sentry_dsn) - serf.loop = get_event_loop() + serf.loop = aio.get_event_loop() try: serf.loop.run_until_complete(serf.run()) except Exception as e: diff --git a/royalnet/serf/telegram/telegramserf.py b/royalnet/serf/telegram/telegramserf.py index fdcbd43c..4a66f18c 100644 --- a/royalnet/serf/telegram/telegramserf.py +++ b/royalnet/serf/telegram/telegramserf.py @@ -1,8 +1,9 @@ import logging -import asyncio -from typing import Type, Optional, List, Callable +import asyncio as aio +from typing import * from royalnet.commands import * from royalnet.utils import asyncify +import royalnet.backpack as rb from .escape import escape from ..serf import Serf @@ -17,15 +18,10 @@ except ImportError: try: from sqlalchemy.orm.session import Session - from ..alchemyconfig import AlchemyConfig except ImportError: Session = None - AlchemyConfig = None -try: - from royalnet.herald import Config as HeraldConfig -except ImportError: - HeraldConfig = None +import royalnet.herald as rh log = logging.getLogger(__name__) @@ -34,25 +30,27 @@ class TelegramSerf(Serf): """A Serf that connects to `Telegram `_ as a bot.""" interface_name = "telegram" - def __init__(self, *, - token: str, - pool_size: int = 8, - read_timeout: int = 60, - alchemy_config: Optional[AlchemyConfig] = None, - commands: List[Type[Command]] = None, - events: List[Type[Event]] = None, - herald_config: Optional[HeraldConfig] = None, - sentry_dsn: Optional[str] = None): + _identity_table = rb.tables.Telegram + _identity_column = "tg_id" + + def __init__(self, + alchemy_cfg: Dict[str, Any], + herald_cfg: Dict[str, Any], + sentry_cfg: Dict[str, Any], + packs_cfg: Dict[str, Any], + serf_cfg: Dict[str, Any]): if telegram is None: raise ImportError("'telegram' extra is not installed") - super().__init__(alchemy_config=alchemy_config, - commands=commands, - events=events, - herald_config=herald_config, - sentry_dsn=sentry_dsn) + super().__init__(alchemy_cfg=alchemy_cfg, + herald_cfg=herald_cfg, + sentry_cfg=sentry_cfg, + packs_cfg=packs_cfg, + serf_cfg=serf_cfg) - self.client = telegram.Bot(token, request=TRequest(pool_size, read_timeout=read_timeout)) + self.client = telegram.Bot(serf_cfg["token"], + request=TRequest(serf_cfg["pool_size"], + read_timeout=serf_cfg["read_timeout"])) """The :class:`telegram.Bot` instance that will be used from the Serf.""" self.update_offset: int = -100 @@ -77,11 +75,11 @@ class TelegramSerf(Serf): break except telegram.error.RetryAfter as error: log.warning(f"Rate limited during {f.__qualname__} (retrying in 15s): {error}") - await asyncio.sleep(15) + await aio.sleep(15) continue except urllib3.exceptions.HTTPError as error: log.warning(f"urllib3 HTTPError during {f.__qualname__} (retrying in 15s): {error}") - await asyncio.sleep(15) + await aio.sleep(15) continue except Exception as error: log.error(f"{error.__class__.__qualname__} during {f} (skipping): {error}") @@ -106,7 +104,7 @@ class TelegramSerf(Serf): def __init__(data, interface: CommandInterface, session, - loop: asyncio.AbstractEventLoop, + loop: aio.AbstractEventLoop, update: telegram.Update): super().__init__(interface=interface, session=session, loop=loop) data.update = update @@ -128,10 +126,10 @@ class TelegramSerf(Serf): if error_if_none: raise CommandError("No command caller for this message") return None - query = data.session.query(self._master_table) - for link in self._identity_chain: + query = data.session.query(self.master_table) + for link in self.identity_chain: query = query.join(link.mapper.class_) - query = query.filter(self._identity_column == user.id) + query = query.filter(self.identity_column == user.id) result = await asyncify(query.one_or_none) if result is None and error_if_none: raise CommandError("Command caller is not registered") diff --git a/sample_config.toml b/sample_config.toml index 2b95e38c..e84332fe 100644 --- a/sample_config.toml +++ b/sample_config.toml @@ -1,11 +1,12 @@ # ROYALNET CONFIGURATION FILE [Herald] -# Please note that either Herald.Local or Herald.Remote should be enabled! +# Enable the herald module, allowing different parts of Royalnet to talk to each other +# Requires the `herald` extra to be installed +enabled = true [Herald.Local] # Run locally a Herald web server (websocket) that other parts of Royalnet can connect to -# Requires the `herald` extra to be installed enabled = true # The address of the network interface on which the Herald server should listen for connections # If 0.0.0.0, listen for connections on all interfaces @@ -91,7 +92,7 @@ log_level = "info" # Requires the `sentry` extra to be installed enabled = false # Get one at https://sentry.io/settings/YOUR-ORG/projects/YOUR-PROJECT/keys/ -sentry_dsn = "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/1111111" +dsn = "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/1111111" [Packs] # The Python package name of the Packs you want to be usable in Royalnet @@ -104,10 +105,9 @@ active = [ # Configuration settings for specific packs # Be aware that packs have access to the whole config file -[Pack] -[Pack.Backpack] +[Packs."royalnet.backpack"] # Enable exception debug commands and stars exc_debug = false # Add your packs config here! -# [Pack.YourPackName] +# [Packs."yourpack"]