1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-27 13:34:28 +00:00

le epic commit

This commit is contained in:
Steffo 2019-11-25 13:31:28 +01:00
parent f63520f385
commit a2f2aa6855
11 changed files with 257 additions and 198 deletions

11
poetry.lock generated
View file

@ -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-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_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"},
{file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"}, {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-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"},
{file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"}, {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-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_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"},
{file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"}, {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-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"},
{file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"}, {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"}, {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"},
] ]
yarl = [ yarl = [

View file

@ -12,6 +12,9 @@ except ImportError:
coloredlogs = None coloredlogs = None
log = getLogger(__name__)
@click.command() @click.command()
@click.option("-c", "--config-filename", default="./config.toml", type=str, @click.option("-c", "--config-filename", default="./config.toml", type=str,
help="The filename of the Royalnet configuration file.") 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}", stream_handler.formatter = Formatter("{asctime}\t| {processName}\t| {name}\t| {message}",
style="{") style="{")
royalnet_log.addHandler(stream_handler) royalnet_log.addHandler(stream_handler)
royalnet_log.info("Logging: ready") log.info("Logging: ready")
herald_process: typing.Optional[multiprocessing.Process] = None herald_process: typing.Optional[multiprocessing.Process] = None
herald_config = r.herald.Config(name="<server>", herald_config = r.herald.Config(name="<server>",

View file

@ -4,11 +4,11 @@ from .telegram import Telegram
from .discord import Discord from .discord import Discord
# Enter the tables of your Pack here! # Enter the tables of your Pack here!
available_tables = [ available_tables = {
User, User,
Telegram, Telegram,
Discord Discord
] }
# Don't change this, it should automatically generate __all__ # Don't change this, it should automatically generate __all__
__all__ = [table.__name__ for table in available_tables] __all__ = [table.__name__ for table in available_tables]

View file

@ -24,9 +24,6 @@ class Command:
"""The syntax of the command, to be displayed when a :py:exc:`InvalidInputError` is raised, """The syntax of the command, to be displayed when a :py:exc:`InvalidInputError` is raised,
in the format ``(required_arg) [optional_arg]``.""" 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): def __init__(self, interface: CommandInterface):
self.interface = interface self.interface = interface

View file

@ -1,7 +1,8 @@
from typing import Optional, TYPE_CHECKING, Awaitable, Any, Callable from typing import *
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from .errors import UnsupportedError from .errors import UnsupportedError
if TYPE_CHECKING: if TYPE_CHECKING:
from .event import Event
from .command import Command from .command import Command
from ..alchemy import Alchemy from ..alchemy import Alchemy
from ..serf import Serf from ..serf import Serf
@ -36,8 +37,13 @@ class CommandInterface:
"""A shortcut for :attr:`serf.loop`.""" """A shortcut for :attr:`serf.loop`."""
return self.serf.loop return self.serf.loop
def __init__(self): def __init__(self, cfg: Dict[str, Any]):
self.command: Optional[Command] = None # Will be bound after the command has been created 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: async def call_herald_event(self, destination: str, event_name: str, **kwargs) -> dict:
"""Call an event function on a different :class:`Serf`. """Call an event function on a different :class:`Serf`.

View file

@ -1,3 +1,4 @@
from .commandinterface import CommandInterface
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from serf import Serf from serf import Serf
@ -9,22 +10,20 @@ class Event:
name = NotImplemented name = NotImplemented
"""The event_name that will trigger this event.""" """The event_name that will trigger this event."""
tables: set = set() def __init__(self, interface: CommandInterface):
"""A set of :mod:`royalnet.alchemy` tables that must exist for this event to work."""
def __init__(self, serf: "Serf"):
"""Bind the event to a :class:`~royalnet.serf.Serf`.""" """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 @property
def alchemy(self): def alchemy(self):
"""A shortcut for :attr:`.serf.alchemy`.""" """A shortcut for :attr:`.serf.alchemy`."""
return self.serf.alchemy return self.interface.serf.alchemy
@property @property
def loop(self): def loop(self):
"""A shortcut for :attr:`.serf.loop`""" """A shortcut for :attr:`.serf.loop`"""
return self.serf.loop return self.interface.serf.loop
async def run(self, **kwargs): async def run(self, **kwargs):
raise NotImplementedError() raise NotImplementedError()

View file

@ -8,7 +8,10 @@ class Config:
port: int, port: int,
secret: str, secret: str,
secure: bool = False, secure: bool = False,
path: str = "/"): path: str = "/",
*,
enabled: ... = ..., # Ignored, but useful to allow creating a config from the config dict
):
if ":" in name: if ":" in name:
raise ValueError("Herald names cannot contain colons (:)") raise ValueError("Herald names cannot contain colons (:)")
self.name = name self.name = name

View file

@ -1,7 +1,8 @@
import asyncio import asyncio
import logging import logging
import warnings import warnings
from typing import Type, Optional, List, Union, Dict from typing import *
import royalnet.backpack as rb
from royalnet.commands import * from royalnet.commands import *
from royalnet.utils import asyncify from royalnet.utils import asyncify
from royalnet.serf import Serf from royalnet.serf import Serf
@ -33,21 +34,25 @@ class DiscordSerf(Serf):
"""A :class:`Serf` that connects to `Discord <https://discordapp.com/>`_ as a bot.""" """A :class:`Serf` that connects to `Discord <https://discordapp.com/>`_ as a bot."""
interface_name = "discord" interface_name = "discord"
def __init__(self, *, _identity_table = rb.tables.Discord
token: str, _identity_column = "discord_id"
alchemy_config: Optional[AlchemyConfig] = None,
commands: List[Type[Command]] = None, def __init__(self,
events: List[Type[Event]] = None, alchemy_cfg: Dict[str, Any],
herald_config: Optional[HeraldConfig] = None): herald_cfg: Dict[str, Any],
sentry_cfg: Dict[str, Any],
packs_cfg: Dict[str, Any],
serf_cfg: Dict[str, Any]):
if discord is None: if discord is None:
raise ImportError("'discord' extra is not installed") raise ImportError("'discord' extra is not installed")
super().__init__(alchemy_config=alchemy_config, super().__init__(alchemy_cfg=alchemy_cfg,
commands=commands, herald_cfg=herald_cfg,
events=events, sentry_cfg=sentry_cfg,
herald_config=herald_config) packs_cfg=packs_cfg,
serf_cfg=serf_cfg)
self.token = token self.token = serf_cfg["token"]
"""The Discord bot token.""" """The Discord bot token."""
self.Client = self.client_factory() self.Client = self.client_factory()
@ -86,10 +91,10 @@ class DiscordSerf(Serf):
async def get_author(data, error_if_none=False): async def get_author(data, error_if_none=False):
user: "discord.Member" = data.message.author user: "discord.Member" = data.message.author
query = data.session.query(self._master_table) query = data.session.query(self.master_table)
for link in self._identity_chain: for link in self.identity_chain:
query = query.join(link.mapper.class_) 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) result = await asyncify(query.one_or_none)
if result is None and error_if_none: if result is None and error_if_none:
raise CommandError("You must be registered to use this command.") raise CommandError("You must be registered to use this command.")

View file

@ -1,30 +1,17 @@
import logging import logging
import sys import sys
import traceback import traceback
from asyncio import Task, AbstractEventLoop, get_event_loop import importlib
from typing import Type, Optional, Awaitable, Dict, List, Any, Callable, Union, Set import asyncio as aio
from typing import *
from sqlalchemy.schema import Table from sqlalchemy.schema import Table
from royalnet import __version__ as version
from royalnet import __version__
from royalnet.commands import * from royalnet.commands import *
from .alchemyconfig import AlchemyConfig import royalnet.alchemy as ra
import royalnet.backpack as rb
try: import royalnet.herald as rh
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
try: try:
import sentry_sdk import sentry_sdk
@ -50,34 +37,91 @@ class Serf:
Discord).""" Discord)."""
interface_name = NotImplemented interface_name = NotImplemented
def __init__(self, *, _master_table: type = rb.tables.User
alchemy_config: Optional[AlchemyConfig] = None, _identity_table: type = NotImplemented
commands: List[Type[Command]] = None, _identity_column: str = NotImplemented
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."""
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`.""" """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 """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.""" many-to-one relationship with the master table."""
# TODO: I'm not sure what this is either # 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") log.info("Alchemy: not installed")
elif alchemy_config is None: elif not alchemy_cfg["enabled"]:
log.info("Alchemy: disabled") log.info("Alchemy: disabled")
else: else:
tables = self.find_tables(alchemy_config, commands) self.init_alchemy(alchemy_cfg["database_url"], tables)
self.init_alchemy(alchemy_config, tables)
log.info(f"Alchemy: {self.alchemy}") 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() self.Interface: Type[CommandInterface] = self.interface_factory()
"""The :class:`CommandInterface` class of this Serf.""" """The :class:`CommandInterface` class of this Serf."""
@ -87,72 +131,59 @@ class Serf:
self.commands: Dict[str, Command] = {} self.commands: Dict[str, Command] = {}
"""The :class:`dict` connecting each command name to its :class:`Command` object.""" """The :class:`dict` connecting each command name to its :class:`Command` object."""
if commands is None: for pack_name in packs:
commands = [] pack = packs[pack_name]
self.register_commands(commands) pack_cfg = packs_cfg.get(pack_name, default={})
log.info(f"Commands: total {len(self.commands)}") 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 if rh.Link is 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:
log.info("Herald: not installed") log.info("Herald: not installed")
elif herald_config is None: elif not herald_cfg["enabled"]:
log.info("Herald: disabled") log.info("Herald: disabled")
else: else:
self.init_herald(herald_config, events) self.init_herald(herald_cfg)
log.info(f"Herald: {len(self.events)} events bound") log.info(f"Herald: enabled")
self.loop: Optional[AbstractEventLoop] = None self.loop: Optional[aio.AbstractEventLoop] = None
"""The event loop this Serf is running on.""" """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.""" """The Sentry DSN / Token. If :const:`None`, Sentry is disabled."""
@staticmethod def init_alchemy(self, alchemy_cfg: Dict[str, Any], tables: Set[type]) -> None:
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:
"""Create and initialize the :class:`Alchemy` with the required tables, and find the link between the master """Create and initialize the :class:`Alchemy` with the required tables, and find the link between the master
table and the identity table.""" table and the identity table."""
self.alchemy = Alchemy(alchemy_config.database_url, tables) self.alchemy = ra.Alchemy(alchemy_cfg["database_url"], tables)
self._master_table = self.alchemy.get(alchemy_config.master_table) self.master_table = self.alchemy.get(self._master_table)
self._identity_table = self.alchemy.get(alchemy_config.identity_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 # This is fine, as Pycharm doesn't know that identity_table is a class and not an object
# noinspection PyArgumentList # noinspection PyArgumentList
self._identity_column = self._identity_table.__getattribute__(self._identity_table, self.identity_column = self.identity_table.__getattribute__(self.identity_table, self._identity_column)
alchemy_config.identity_column)
@property @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.""" """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]: def interface_factory(self) -> Type[CommandInterface]:
"""Create the :class:`CommandInterface` class for the Serf.""" """Create the :class:`CommandInterface` class for the Serf."""
# noinspection PyMethodParameters # noinspection PyMethodParameters
class GenericInterface(CommandInterface): class GenericInterface(CommandInterface):
alchemy: Alchemy = self.alchemy alchemy: ra.Alchemy = self.alchemy
serf: "Serf" = self serf: "Serf" = self
async def call_herald_event(ci, destination: str, event_name: str, **kwargs) -> Dict: async def call_herald_event(ci, destination: str, event_name: str, **kwargs) -> Dict:
@ -160,9 +191,9 @@ class Serf:
:class:`royalherald.Response`.""" :class:`royalherald.Response`."""
if self.herald is None: if self.herald is None:
raise UnsupportedError("`royalherald` is not enabled on this bot.") raise UnsupportedError("`royalherald` is not enabled on this bot.")
request: Request = Request(handler=event_name, data=kwargs) request: rh.Request = rh.Request(handler=event_name, data=kwargs)
response: Response = await self.herald.request(destination=destination, request=request) response: rh.Response = await self.herald.request(destination=destination, request=request)
if isinstance(response, ResponseFailure): if isinstance(response, rh.ResponseFailure):
if response.name == "no_event": if response.name == "no_event":
raise CommandError(f"There is no event named {event_name} in {destination}.") raise CommandError(f"There is no event named {event_name} in {destination}.")
elif response.name == "exception_in_event": elif response.name == "exception_in_event":
@ -182,7 +213,7 @@ class Serf:
else: else:
raise TypeError(f"Herald action call returned invalid error:\n" raise TypeError(f"Herald action call returned invalid error:\n"
f"[p]{response}[/p]") f"[p]{response}[/p]")
elif isinstance(response, ResponseSuccess): elif isinstance(response, rh.ResponseSuccess):
return response.data return response.data
else: else:
raise TypeError(f"Other Herald Link returned unknown response:\n" raise TypeError(f"Other Herald Link returned unknown response:\n"
@ -194,35 +225,29 @@ class Serf:
"""Create the :class:`CommandData` for the Serf.""" """Create the :class:`CommandData` for the Serf."""
raise NotImplementedError() raise NotImplementedError()
def register_commands(self, commands: List[Type[Command]]) -> None: def register_commands(self, commands: List[Type[Command]], pack_cfg: Dict[str, Any]) -> None:
"""Initialize and register all commands passed as argument. """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...")
# Instantiate the Commands # Instantiate the Commands
for SelectedCommand in 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 # Create a new interface
interface = self.Interface() interface = self.Interface(cfg=pack_cfg)
# Try to instantiate the command # Try to instantiate the command
try: try:
command = SelectedCommand(interface) command = SelectedCommand(interface)
except Exception as e: except Exception as e:
log.error(f"Skipping: " log.error(f"Skipping: "
f"{SelectedCommand.__qualname__} - {e.__class__.__qualname__} in the initialization.") f"{SelectedCommand.__qualname__} - {e.__class__.__qualname__} in the initialization.")
sentry_sdk.capture_exception(e) self.sentry_exc(e)
continue continue
# Link the interface to the command # Link the interface to the command
interface.command = 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 # Register the command in the commands dict
self.commands[f"{interface.prefix}{SelectedCommand.name}"] = command self.commands[f"{interface.prefix}{SelectedCommand.name}"] = command
# Register aliases, but don't override anything # Register aliases, but don't override anything
@ -232,52 +257,65 @@ class Serf:
self.commands[f"{interface.prefix}{alias}"] = \ self.commands[f"{interface.prefix}{alias}"] = \
self.commands[f"{interface.prefix}{SelectedCommand.name}"] self.commands[f"{interface.prefix}{SelectedCommand.name}"]
else: 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]]): def init_herald(self, herald_cfg: Dict[str, Any]):
"""Create a :py:class:`Link`, and run it as a :py:class:`asyncio.Task`.""" """Create a :class:`Link` and bind :class:`Event`."""
self.herald: Link = Link(config, self.network_handler) herald_cfg["name"] = self.interface_name
log.debug(f"Binding events...") 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: for SelectedEvent in events:
log.debug(f"Binding event: {SelectedEvent.name}.") # Create a new interface
self.events[SelectedEvent.name] = SelectedEvent(self) 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: try:
event: Event = self.events[message.handler] event: Event = self.events[message.handler]
except KeyError: except KeyError:
log.warning(f"No event for '{message.handler}'") 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}") log.debug(f"Event called: {event.name}")
if isinstance(message, Request): if isinstance(message, rh.Request):
try: try:
response_data = await event.run(**message.data) response_data = await event.run(**message.data)
return ResponseSuccess(data=response_data) return rh.ResponseSuccess(data=response_data)
except Exception as e: except Exception as e:
self.sentry_exc(e) self.sentry_exc(e)
return ResponseFailure("exception_in_event", return rh.ResponseFailure("exception_in_event",
f"An exception was raised in the event for '{message.handler}'.", f"An exception was raised in the event for '{message.handler}'.",
extra_info={ extra_info={
"type": e.__class__.__qualname__, "type": e.__class__.__qualname__,
"message": str(e) "message": str(e)
}) })
elif isinstance(message, Broadcast): elif isinstance(message, rh.Broadcast):
await event.run(**message.data) await event.run(**message.data)
@staticmethod @staticmethod
def init_sentry(dsn): def init_sentry(dsn):
# noinspection PyUnreachableCode
if __debug__:
release = f"royalnet"
else:
release = f"royalnet=={version}"
log.debug("Initializing Sentry...") log.debug("Initializing Sentry...")
release = f"royalnet@{__version__}"
sentry_sdk.init(dsn, sentry_sdk.init(dsn,
integrations=[AioHttpIntegration(), integrations=[AioHttpIntegration(),
SqlalchemyIntegration(), SqlalchemyIntegration(),
LoggingIntegration(event_level=None)], LoggingIntegration(event_level=None)],
release=release) release=release)
log.info(f"Sentry: enabled (release {release})") log.info(f"Sentry: {release}")
# noinspection PyUnreachableCode # noinspection PyUnreachableCode
@staticmethod @staticmethod
@ -343,13 +381,12 @@ class Serf:
if sentry_sdk is None: if sentry_sdk is None:
log.info("Sentry: not installed") log.info("Sentry: not installed")
else: elif serf.sentry_dsn is None:
if serf.sentry_dsn is None:
log.info("Sentry: disabled") log.info("Sentry: disabled")
else: 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: try:
serf.loop.run_until_complete(serf.run()) serf.loop.run_until_complete(serf.run())
except Exception as e: except Exception as e:

View file

@ -1,8 +1,9 @@
import logging import logging
import asyncio import asyncio as aio
from typing import Type, Optional, List, Callable from typing import *
from royalnet.commands import * from royalnet.commands import *
from royalnet.utils import asyncify from royalnet.utils import asyncify
import royalnet.backpack as rb
from .escape import escape from .escape import escape
from ..serf import Serf from ..serf import Serf
@ -17,15 +18,10 @@ except ImportError:
try: try:
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from ..alchemyconfig import AlchemyConfig
except ImportError: except ImportError:
Session = None Session = None
AlchemyConfig = None
try: import royalnet.herald as rh
from royalnet.herald import Config as HeraldConfig
except ImportError:
HeraldConfig = None
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -34,25 +30,27 @@ class TelegramSerf(Serf):
"""A Serf that connects to `Telegram <https://telegram.org/>`_ as a bot.""" """A Serf that connects to `Telegram <https://telegram.org/>`_ as a bot."""
interface_name = "telegram" interface_name = "telegram"
def __init__(self, *, _identity_table = rb.tables.Telegram
token: str, _identity_column = "tg_id"
pool_size: int = 8,
read_timeout: int = 60, def __init__(self,
alchemy_config: Optional[AlchemyConfig] = None, alchemy_cfg: Dict[str, Any],
commands: List[Type[Command]] = None, herald_cfg: Dict[str, Any],
events: List[Type[Event]] = None, sentry_cfg: Dict[str, Any],
herald_config: Optional[HeraldConfig] = None, packs_cfg: Dict[str, Any],
sentry_dsn: Optional[str] = None): serf_cfg: Dict[str, Any]):
if telegram is None: if telegram is None:
raise ImportError("'telegram' extra is not installed") raise ImportError("'telegram' extra is not installed")
super().__init__(alchemy_config=alchemy_config, super().__init__(alchemy_cfg=alchemy_cfg,
commands=commands, herald_cfg=herald_cfg,
events=events, sentry_cfg=sentry_cfg,
herald_config=herald_config, packs_cfg=packs_cfg,
sentry_dsn=sentry_dsn) 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.""" """The :class:`telegram.Bot` instance that will be used from the Serf."""
self.update_offset: int = -100 self.update_offset: int = -100
@ -77,11 +75,11 @@ class TelegramSerf(Serf):
break break
except telegram.error.RetryAfter as error: except telegram.error.RetryAfter as error:
log.warning(f"Rate limited during {f.__qualname__} (retrying in 15s): {error}") log.warning(f"Rate limited during {f.__qualname__} (retrying in 15s): {error}")
await asyncio.sleep(15) await aio.sleep(15)
continue continue
except urllib3.exceptions.HTTPError as error: except urllib3.exceptions.HTTPError as error:
log.warning(f"urllib3 HTTPError during {f.__qualname__} (retrying in 15s): {error}") log.warning(f"urllib3 HTTPError during {f.__qualname__} (retrying in 15s): {error}")
await asyncio.sleep(15) await aio.sleep(15)
continue continue
except Exception as error: except Exception as error:
log.error(f"{error.__class__.__qualname__} during {f} (skipping): {error}") log.error(f"{error.__class__.__qualname__} during {f} (skipping): {error}")
@ -106,7 +104,7 @@ class TelegramSerf(Serf):
def __init__(data, def __init__(data,
interface: CommandInterface, interface: CommandInterface,
session, session,
loop: asyncio.AbstractEventLoop, loop: aio.AbstractEventLoop,
update: telegram.Update): update: telegram.Update):
super().__init__(interface=interface, session=session, loop=loop) super().__init__(interface=interface, session=session, loop=loop)
data.update = update data.update = update
@ -128,10 +126,10 @@ class TelegramSerf(Serf):
if error_if_none: if error_if_none:
raise CommandError("No command caller for this message") raise CommandError("No command caller for this message")
return None return None
query = data.session.query(self._master_table) query = data.session.query(self.master_table)
for link in self._identity_chain: for link in self.identity_chain:
query = query.join(link.mapper.class_) 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) result = await asyncify(query.one_or_none)
if result is None and error_if_none: if result is None and error_if_none:
raise CommandError("Command caller is not registered") raise CommandError("Command caller is not registered")

View file

@ -1,11 +1,12 @@
# ROYALNET CONFIGURATION FILE # ROYALNET CONFIGURATION FILE
[Herald] [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] [Herald.Local]
# Run locally a Herald web server (websocket) that other parts of Royalnet can connect to # Run locally a Herald web server (websocket) that other parts of Royalnet can connect to
# Requires the `herald` extra to be installed
enabled = true enabled = true
# The address of the network interface on which the Herald server should listen for connections # 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 # 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 # Requires the `sentry` extra to be installed
enabled = false enabled = false
# Get one at https://sentry.io/settings/YOUR-ORG/projects/YOUR-PROJECT/keys/ # 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] [Packs]
# The Python package name of the Packs you want to be usable in Royalnet # The Python package name of the Packs you want to be usable in Royalnet
@ -104,10 +105,9 @@ active = [
# Configuration settings for specific packs # Configuration settings for specific packs
# Be aware that packs have access to the whole config file # Be aware that packs have access to the whole config file
[Pack] [Packs."royalnet.backpack"]
[Pack.Backpack]
# Enable exception debug commands and stars # Enable exception debug commands and stars
exc_debug = false exc_debug = false
# Add your packs config here! # Add your packs config here!
# [Pack.YourPackName] # [Packs."yourpack"]