mirror of
https://github.com/RYGhub/royalnet.git
synced 2024-11-23 19:44:20 +00:00
337 lines
16 KiB
Python
337 lines
16 KiB
Python
import abc
|
|
import asyncio as aio
|
|
import importlib
|
|
import logging
|
|
import sys
|
|
import traceback
|
|
from typing import *
|
|
|
|
from sqlalchemy.schema import Table
|
|
|
|
import royalnet.alchemy as ra
|
|
import royalnet.backpack.tables as rbt
|
|
import royalnet.commands as rc
|
|
import royalnet.herald as rh
|
|
import royalnet.utils as ru
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class Serf(abc.ABC):
|
|
"""An abstract class, to be used as base to implement Royalnet bots on multiple interfaces (such as Telegram or
|
|
Discord)."""
|
|
interface_name = NotImplemented
|
|
prefix = NotImplemented
|
|
|
|
_master_table: type = rbt.User
|
|
_identity_table: type = NotImplemented
|
|
_identity_column: str = NotImplemented
|
|
|
|
def __init__(self,
|
|
loop: aio.AbstractEventLoop,
|
|
alchemy_cfg: rc.ConfigDict,
|
|
herald_cfg: rc.ConfigDict,
|
|
packs_cfg: rc.ConfigDict,
|
|
**_):
|
|
self.loop: Optional[aio.AbstractEventLoop] = loop
|
|
"""The event loop this Serf is running on."""
|
|
|
|
self.tasks: Optional[ru.TaskList] = ru.TaskList(self.loop)
|
|
"""A list of all running tasks of the serf. Initialized at the serf start."""
|
|
|
|
# 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] = {
|
|
"commands": importlib.import_module(f".commands", pack_name),
|
|
"events": importlib.import_module(f".events", pack_name),
|
|
"stars": importlib.import_module(f".stars", pack_name),
|
|
"tables": importlib.import_module(f".tables", pack_name),
|
|
}
|
|
except ImportError as e:
|
|
log.error(f"{e.__class__.__name__} during the import of {pack_name}:\n"
|
|
f"{''.join(traceback.format_exception(*sys.exc_info()))}")
|
|
log.info(f"Packs: {len(packs)} imported")
|
|
|
|
self.alchemy: Optional[ra.Alchemy] = None
|
|
"""The :class:`Alchemy` object connecting this :class:`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
|
|
"""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."""
|
|
|
|
self.identity_column: Optional[str] = None
|
|
"""The name of the column in the identity table that contains a unique user identifier. (???)"""
|
|
|
|
# Alchemy
|
|
if ra.Alchemy is None:
|
|
log.info("Alchemy: not installed")
|
|
elif not alchemy_cfg["enabled"]:
|
|
log.info("Alchemy: disabled")
|
|
else:
|
|
# Find all tables
|
|
tables = set()
|
|
for pack in packs.values():
|
|
try:
|
|
# noinspection PyUnresolvedReferences
|
|
tables = tables.union(pack["tables"].available_tables)
|
|
except AttributeError:
|
|
log.warning(f"Pack `{pack}` does not have the `available_tables` attribute.")
|
|
continue
|
|
# Create the Alchemy
|
|
self.init_alchemy(alchemy_cfg, tables)
|
|
log.info(f"Alchemy: {self.alchemy}")
|
|
|
|
self.herald: Optional[rh.Link] = None
|
|
"""The :class:`Link` object connecting the :class:`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, rc.HeraldEvent] = {}
|
|
"""A dictionary containing all :class:`Event` that can be handled by this :class:`Serf`."""
|
|
|
|
self.commands: Dict[str, rc.Command] = {}
|
|
"""The :class:`dict` connecting each command name to its :class:`Command` object."""
|
|
|
|
for pack_name in packs:
|
|
pack = packs[pack_name]
|
|
pack_cfg = packs_cfg.get(pack_name, {})
|
|
try:
|
|
# noinspection PyUnresolvedReferences
|
|
events = pack["events"].available_events
|
|
except AttributeError:
|
|
log.warning(f"Pack `{pack}` does not have the `available_events` attribute.")
|
|
else:
|
|
self.register_events(events, pack_cfg)
|
|
try:
|
|
# noinspection PyUnresolvedReferences
|
|
commands = pack["commands"].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.events)} events")
|
|
log.info(f"Commands: {len(self.commands)} commands")
|
|
|
|
if rh.Link is None:
|
|
log.info("Herald: not installed")
|
|
elif not herald_cfg["enabled"]:
|
|
log.info("Herald: disabled")
|
|
else:
|
|
self.init_herald(herald_cfg)
|
|
log.info(f"Herald: enabled")
|
|
|
|
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 = ra.Alchemy(alchemy_cfg["database_url"], tables)
|
|
|
|
@property
|
|
def identity_chain(self) -> tuple:
|
|
"""Find a relationship path starting from the master table and ending at the identity table, and return it."""
|
|
return ra.table_dfs(self.master_table, self.identity_table)
|
|
|
|
async def call_herald_event(self, destination: str, event_name: str, **kwargs) -> Dict:
|
|
"""Send a :class:`royalherald.Request` to a specific destination, and wait for a
|
|
:class:`royalherald.Response`."""
|
|
if self.herald is None:
|
|
raise rc.UnsupportedError("`royalherald` is not enabled on this serf.")
|
|
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 rc.ProgramError(f"There is no event named {event_name} in {destination}.")
|
|
elif response.name == "error_in_event":
|
|
if response.extra_info["type"] == "CommandError":
|
|
raise rc.CommandError(response.extra_info["message"])
|
|
elif response.extra_info["type"] == "UserError":
|
|
raise rc.UserError(response.extra_info["message"])
|
|
elif response.extra_info["type"] == "InvalidInputError":
|
|
raise rc.InvalidInputError(response.extra_info["message"])
|
|
elif response.extra_info["type"] == "UnsupportedError":
|
|
raise rc.UnsupportedError(response.extra_info["message"])
|
|
elif response.extra_info["type"] == "ConfigurationError":
|
|
raise rc.ConfigurationError(response.extra_info["message"])
|
|
elif response.extra_info["type"] == "ExternalError":
|
|
raise rc.ExternalError(response.extra_info["message"])
|
|
else:
|
|
raise rc.ProgramError(f"Invalid error in Herald event '{event_name}':\n"
|
|
f"[b]{response.extra_info['type']}[/b]\n"
|
|
f"{response.extra_info['message']}")
|
|
elif response.name == "unhandled_exception_in_event":
|
|
raise rc.ProgramError(f"Unhandled exception in Herald event '{event_name}':\n"
|
|
f"[b]{response.extra_info['type']}[/b]\n"
|
|
f"{response.extra_info['message']}")
|
|
else:
|
|
raise rc.ProgramError(f"Unknown response in Herald event '{event_name}':\n"
|
|
f"[b]{response.name}[/b]"
|
|
f"[p]{response}[/p]")
|
|
elif isinstance(response, rh.ResponseSuccess):
|
|
return response.data
|
|
else:
|
|
raise rc.ProgramError(f"Other Herald Link returned unknown response:\n"
|
|
f"[p]{response}[/p]")
|
|
|
|
def register_commands(self, commands: List[Type[rc.Command]], pack_cfg: rc.ConfigDict) -> None:
|
|
"""Initialize and register all commands passed as argument."""
|
|
# Instantiate the Commands
|
|
for SelectedCommand in commands:
|
|
# Try to instantiate the command
|
|
try:
|
|
command = SelectedCommand(serf=self, config=pack_cfg)
|
|
except Exception as e:
|
|
log.error(f"Skipping: "
|
|
f"{SelectedCommand.__qualname__} - {e.__class__.__qualname__} in the initialization.")
|
|
ru.sentry_exc(e)
|
|
continue
|
|
# Warn if the command would be overriding something
|
|
if SelectedCommand.name in self.commands:
|
|
log.info(f"Overriding (already defined): "
|
|
f"{SelectedCommand.__qualname__} -> {SelectedCommand.name}")
|
|
else:
|
|
log.debug(f"Registering: "
|
|
f"{SelectedCommand.__qualname__} -> {SelectedCommand.name}")
|
|
# Register the command in the commands dict
|
|
self.commands[SelectedCommand.name] = command
|
|
# Register aliases, but don't override anything
|
|
for alias in SelectedCommand.aliases:
|
|
if alias not in self.commands:
|
|
log.debug(f"Aliasing: {SelectedCommand.__qualname__} -> {alias}")
|
|
self.commands[alias] = self.commands[SelectedCommand.name]
|
|
else:
|
|
log.warning(f"Ignoring (already defined): {SelectedCommand.__qualname__} -> {alias}")
|
|
|
|
def init_herald(self, herald_cfg: rc.ConfigDict):
|
|
"""Create a :class:`Link` and bind :class:`Event`."""
|
|
herald_cfg["name"] = self.interface_name
|
|
self.herald: rh.Link = rh.Link(rh.Config.from_config(**herald_cfg), self.network_handler)
|
|
|
|
def register_events(self, events: List[Type[rc.HeraldEvent]], pack_cfg: rc.ConfigDict):
|
|
for SelectedEvent in events:
|
|
# Initialize the event
|
|
try:
|
|
event = SelectedEvent(parent=self, config=pack_cfg)
|
|
except Exception as e:
|
|
log.error(f"Skipping: "
|
|
f"{SelectedEvent.__qualname__} - {e.__class__.__qualname__} in the initialization.")
|
|
ru.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[rh.Request, rh.Broadcast]) -> rh.Response:
|
|
try:
|
|
event: rc.HeraldEvent = self.events[message.handler]
|
|
except KeyError:
|
|
log.warning(f"No 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, rh.Request):
|
|
try:
|
|
response_data = await event.run(**message.data)
|
|
return rh.ResponseSuccess(data=response_data)
|
|
except rc.CommandError as e:
|
|
return rh.ResponseFailure("error_in_event",
|
|
f"The event '{message.handler}' raised a {e.__class__.__qualname__}.",
|
|
extra_info={
|
|
"type": e.__class__.__qualname__,
|
|
"message": str(e)
|
|
})
|
|
except Exception as e:
|
|
ru.sentry_exc(e)
|
|
return rh.ResponseFailure("unhandled_exception_in_event",
|
|
f"The event '{message.handler}' raised an unhandled"
|
|
f" {e.__class__.__qualname__}.",
|
|
extra_info={
|
|
"type": e.__class__.__qualname__,
|
|
"message": str(e)
|
|
})
|
|
elif isinstance(message, rh.Broadcast):
|
|
await event.run(**message.data)
|
|
|
|
async def call(self, command: rc.Command, data: rc.CommandData, parameters: List[str]):
|
|
log.info(f"Calling command: {command.name}")
|
|
try:
|
|
# Run the command
|
|
await command.run(rc.CommandArgs(parameters), data)
|
|
except rc.InvalidInputError as e:
|
|
await data.reply(f"⚠️ {e.message}\n"
|
|
f"Syntax: [c]{self.prefix}{command.name} {command.syntax}[/c]")
|
|
except rc.UserError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.UnsupportedError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.ExternalError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.ConfigurationError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.ProgramError as e:
|
|
await data.reply(f"⛔️ {e.message}")
|
|
except rc.CommandError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except Exception as e:
|
|
ru.sentry_exc(e)
|
|
await data.reply(f"⛔️ [b]{e.__class__.__name__}[/b]\n" + '\n'.join(map(lambda a: repr(a), e.args)))
|
|
|
|
@staticmethod
|
|
async def press(key: rc.KeyboardKey, data: rc.CommandData):
|
|
log.info(f"Calling key_callback: {repr(key)}")
|
|
try:
|
|
await key.press(data)
|
|
except rc.InvalidInputError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.UserError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.UnsupportedError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.ExternalError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.ConfigurationError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except rc.ProgramError as e:
|
|
await data.reply(f"⛔️ {e.message}")
|
|
except rc.CommandError as e:
|
|
await data.reply(f"⚠️ {e.message}")
|
|
except Exception as e:
|
|
ru.sentry_exc(e)
|
|
await data.reply(f"⛔️ [b]{e.__class__.__name__}[/b]\n" + '\n'.join(map(lambda a: repr(a), e.args)))
|
|
|
|
async def run(self):
|
|
"""A coroutine that starts the event loop and handles command calls."""
|
|
self.herald_task = self.tasks.add(self.herald.run())
|
|
# OVERRIDE THIS METHOD!
|
|
|
|
@classmethod
|
|
def run_process(cls, **kwargs):
|
|
"""Blockingly create and run the Serf.
|
|
|
|
This should be used as the target of a :class:`multiprocessing.Process`."""
|
|
ru.init_logging(kwargs["logging_cfg"])
|
|
|
|
if kwargs["sentry_cfg"] is None or not kwargs["sentry_cfg"]["enabled"]:
|
|
log.info("Sentry: disabled")
|
|
else:
|
|
try:
|
|
ru.init_sentry(kwargs["sentry_cfg"])
|
|
except ImportError:
|
|
log.info("Sentry: not installed")
|
|
|
|
loop = aio.get_event_loop()
|
|
|
|
serf = cls(loop=loop, **kwargs)
|
|
|
|
try:
|
|
serf.loop.run_until_complete(serf.run())
|
|
except Exception as e:
|
|
ru.sentry_exc(e, level="fatal")
|