1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-27 13:34:28 +00:00
This commit is contained in:
Steffo 2019-04-19 19:43:24 +02:00
parent 350d754683
commit 10c77a20d0
10 changed files with 134 additions and 126 deletions

View file

@ -1,7 +1,7 @@
import os
import asyncio
import logging
from royalnet.bots import DiscordBot, DiscordConfig
from royalnet.bots import DiscordBot, DiscordConfig, TelegramBot, TelegramConfig
from royalnet.commands import *
from royalnet.commands.debug_create import DebugCreateCommand
from royalnet.commands.error_handler import ErrorHandlerCommand
@ -12,8 +12,12 @@ from royalnet.database.tables import Royal, Telegram, Discord
loop = asyncio.get_event_loop()
log = logging.root
log.addHandler(logging.StreamHandler())
stream_handler = logging.StreamHandler()
stream_handler.formatter = logging.Formatter("{asctime}\t{name}\t{levelname}\t{message}", style="{")
log.addHandler(stream_handler)
logging.getLogger("royalnet.bots.generic").setLevel(logging.DEBUG)
logging.getLogger("royalnet.bots.discord").setLevel(logging.DEBUG)
logging.getLogger("royalnet.bots.telegram").setLevel(logging.DEBUG)
commands = [PingCommand, ShipCommand, SmecdsCommand, ColorCommand, CiaoruoziCommand, DebugCreateCommand, SyncCommand,
AuthorCommand, DiarioCommand, RageCommand, DateparserCommand, ReminderCommand, KvactiveCommand, KvCommand,
@ -27,7 +31,13 @@ ds_bot = DiscordBot(discord_config=DiscordConfig(os.environ["DS_AK"]),
database_config=DatabaseConfig(os.environ["DB_PATH"], Royal, Discord, "discord_id"),
commands=commands,
error_command=ErrorHandlerCommand)
tg_bot = TelegramBot(telegram_config=TelegramConfig(os.environ["TG_AK"]),
royalnet_config=RoyalnetConfig(f"ws://{address}:{port}", "sas"),
database_config=DatabaseConfig(os.environ["DB_PATH"], Royal, Telegram, "tg_id"),
commands=commands,
error_command=ErrorHandlerCommand)
loop.run_until_complete(master.run())
loop.create_task(tg_bot.run())
loop.create_task(ds_bot.run())
print("Starting loop...")
loop.run_forever()

View file

@ -1,4 +1,4 @@
from .telegram import TelegramBot
from .telegram import TelegramBot, TelegramConfig
from .discord import DiscordBot, DiscordConfig
__all__ = ["TelegramBot", "DiscordBot", "DiscordConfig"]
__all__ = ["TelegramBot", "TelegramConfig", "DiscordBot", "DiscordConfig"]

View file

@ -6,7 +6,7 @@ from .generic import GenericBot
from ..commands import NullCommand
from ..utils import asyncify, Call, Command
from ..error import UnregisteredError, NoneFoundError, TooManyFoundError, InvalidConfigError
from ..network import Message, RequestError, RoyalnetConfig
from ..network import Message, RoyalnetConfig
from ..database import DatabaseConfig
from ..audio import PlayMode, Playlist
@ -27,9 +27,12 @@ class DiscordBot(GenericBot):
interface_name = "discord"
def _init_voice(self):
log.debug(f"Creating music_data dict")
self.music_data: typing.Dict[discord.Guild, PlayMode] = {}
def _call_factory(self) -> typing.Type[Call]:
log.debug(f"Creating DiscordCall")
# noinspection PyMethodParameters
class DiscordCall(Call):
interface_name = self.interface_name
@ -56,9 +59,8 @@ class DiscordBot(GenericBot):
async def net_request(call, message: Message, destination: str):
if self.network is None:
raise InvalidConfigError("Royalnet is not enabled on this bot")
response = await self.network.request(message, destination)
if isinstance(response, RequestError):
raise response.exc
response: Message = await self.network.request(message, destination)
response.raise_on_error()
return response
async def get_author(call, error_if_none=False):
@ -77,12 +79,15 @@ class DiscordBot(GenericBot):
def _bot_factory(self) -> typing.Type[discord.Client]:
"""Create a new DiscordClient class based on this DiscordBot."""
log.debug(f"Creating DiscordClient")
# noinspection PyMethodParameters
class DiscordClient(discord.Client):
async def vc_connect_or_move(cli, channel: discord.VoiceChannel):
# Connect to voice chat
try:
await channel.connect()
log.debug(f"Connecting to Voice in {channel}")
except discord.errors.ClientException:
# Move to the selected channel, instead of connecting
# noinspection PyUnusedLocal
@ -91,8 +96,10 @@ class DiscordBot(GenericBot):
if voice_client.guild != channel.guild:
continue
await voice_client.move_to(channel)
log.debug(f"Moved {voice_client} to {channel}")
# Create a music_data entry, if it doesn't exist; default is a Playlist
if not self.music_data.get(channel.guild):
log.debug(f"Creating music_data for {channel.guild}")
self.music_data[channel.guild] = Playlist()
@staticmethod # Not really static because of the self reference
@ -157,17 +164,21 @@ class DiscordBot(GenericBot):
def _init_client(self):
"""Create a bot instance."""
self.client = self._bot_factory()()
log.debug(f"Creating DiscordClient instance")
self._Client = self._bot_factory()
self.client = self._Client()
def __init__(self, *,
discord_config: DiscordConfig,
royalnet_config: RoyalnetConfig,
royalnet_config: typing.Optional[RoyalnetConfig] = None,
database_config: typing.Optional[DatabaseConfig] = None,
command_prefix: str = "!",
commands: typing.List[typing.Type[Command]] = None,
missing_command: typing.Type[Command] = NullCommand,
error_command: typing.Type[Command] = NullCommand):
super().__init__(royalnet_config=royalnet_config,
database_config=database_config,
command_prefix=command_prefix,
commands=commands,
missing_command=missing_command,
error_command=error_command)
@ -176,7 +187,9 @@ class DiscordBot(GenericBot):
self._init_voice()
async def run(self):
log.debug(f"Logging in to Discord")
await self.client.login(self._discord_config.token)
log.debug(f"Connecting to Discord")
await self.client.connect()
# TODO: how to stop?

View file

@ -16,6 +16,7 @@ class GenericBot:
interface_name = NotImplemented
def _init_commands(self,
command_prefix: str,
commands: typing.List[typing.Type[Command]],
missing_command: typing.Type[Command],
error_command: typing.Type[Command]):
@ -24,7 +25,7 @@ class GenericBot:
self.commands: typing.Dict[str, typing.Type[Command]] = {}
self.network_handlers: typing.Dict[typing.Type[Message], typing.Type[NetworkHandler]] = {}
for command in commands:
self.commands[f"!{command.command_name}"] = command
self.commands[f"{command_prefix}{command.command_name}"] = command
self.network_handlers = {**self.network_handlers, **command.network_handler_dict()}
self.missing_command: typing.Type[Command] = missing_command
self.error_command: typing.Type[Command] = error_command
@ -36,7 +37,7 @@ class GenericBot:
def _init_royalnet(self, royalnet_config: RoyalnetConfig):
"""Create a RoyalnetLink, and run it as a task."""
self.network: RoyalnetLink = RoyalnetLink(royalnet_config.master_uri, royalnet_config.master_secret, "discord",
self.network: RoyalnetLink = RoyalnetLink(royalnet_config.master_uri, royalnet_config.master_secret, self.interface_name,
self._network_handler)
log.debug(f"Running RoyalnetLink {self.network}")
loop.create_task(self.network.run())
@ -46,15 +47,17 @@ class GenericBot:
log.debug(f"Received {message} from the RoyalnetLink")
try:
network_handler = self.network_handlers[message.__class__]
except KeyError as exc:
except KeyError:
_, exc, tb = sys.exc_info()
log.debug(f"Missing network_handler for {message}")
return RequestError(KeyError("Missing network_handler"))
return RequestError(exc=exc)
try:
log.debug(f"Using {network_handler} as handler for {message}")
return await getattr(network_handler, self.interface_name)(message)
except Exception as exc:
return await getattr(network_handler, self.interface_name)(self, message)
except Exception:
_, exc, tb = sys.exc_info()
log.debug(f"Exception {exc} in {network_handler}")
return RequestError(exc)
return RequestError(exc=exc)
def _init_database(self, commands: typing.List[typing.Type[Command]], database_config: DatabaseConfig):
"""Connect to the database, and create the missing tables required by the selected commands."""
@ -74,6 +77,7 @@ class GenericBot:
def __init__(self, *,
royalnet_config: typing.Optional[RoyalnetConfig] = None,
database_config: typing.Optional[DatabaseConfig] = None,
command_prefix: str,
commands: typing.List[typing.Type[Command]] = None,
missing_command: typing.Type[Command] = NullCommand,
error_command: typing.Type[Command] = NullCommand):
@ -86,7 +90,7 @@ class GenericBot:
self._init_database(commands=commands, database_config=database_config)
if commands is None:
commands = []
self._init_commands(commands, missing_command=missing_command, error_command=error_command)
self._init_commands(command_prefix, commands, missing_command=missing_command, error_command=error_command)
self._Call = self._call_factory()
if royalnet_config is None:
self.network = None
@ -95,15 +99,18 @@ class GenericBot:
async def call(self, command_name: str, channel, parameters: typing.List[str] = None, **kwargs):
"""Call a command by its string, or missing_command if it doesn't exists, or error_command if an exception is raised during the execution."""
log.debug(f"Trying to call {command_name}")
if parameters is None:
parameters = []
try:
command: typing.Type[Command] = self.commands[command_name]
except KeyError:
log.debug(f"Calling missing_command because {command_name} does not exist")
command = self.missing_command
try:
await self._Call(channel, command, parameters, **kwargs).run()
except Exception as exc:
log.debug(f"Calling error_command because of an error in {command_name}")
await self._Call(channel, self.error_command,
exception_info=sys.exc_info(),
previous_command=command, **kwargs).run()

View file

@ -3,10 +3,11 @@ import asyncio
import typing
import logging as _logging
import sys
from .generic import GenericBot
from ..commands import NullCommand
from ..utils import asyncify, Call, Command
from ..error import UnregisteredError, InvalidConfigError
from ..network import RoyalnetLink, Message, RequestError
from ..network import RoyalnetLink, Message, RequestError, RoyalnetConfig
from ..database import Alchemy, relationshiplinkchain, DatabaseConfig
loop = asyncio.get_event_loop()
@ -17,48 +18,25 @@ async def todo(message: Message):
log.warning(f"Skipped {message} because handling isn't supported yet.")
class TelegramBot:
def __init__(self,
api_key: str,
master_server_uri: str,
master_server_secret: str,
commands: typing.List[typing.Type[Command]],
missing_command: typing.Type[Command] = NullCommand,
error_command: typing.Type[Command] = NullCommand,
database_config: typing.Optional[DatabaseConfig] = None):
self.bot: telegram.Bot = telegram.Bot(api_key)
self.should_run: bool = False
self.offset: int = -100
self.missing_command = missing_command
self.error_command = error_command
self.network: RoyalnetLink = RoyalnetLink(master_server_uri, master_server_secret, "telegram", todo)
loop.create_task(self.network.run())
# Generate _commands
self.commands = {}
required_tables = set()
for command in commands:
self.commands[f"/{command.command_name}"] = command
required_tables = required_tables.union(command.require_alchemy_tables)
# Generate the Alchemy database
if database_config:
self.alchemy = Alchemy(database_config.database_uri, required_tables)
self.master_table = self.alchemy.__getattribute__(database_config.master_table.__name__)
self.identity_table = self.alchemy.__getattribute__(database_config.identity_table.__name__)
self.identity_column = self.identity_table.__getattribute__(self.identity_table, database_config.identity_column_name)
self.identity_chain = relationshiplinkchain(self.master_table, self.identity_table)
else:
if required_tables:
raise InvalidConfigError("Tables are required by the _commands, but Alchemy is not configured")
self.alchemy = None
self.master_table = None
self.identity_table = None
self.identity_column = None
self.identity_chain = None
class TelegramConfig:
def __init__(self, token: str):
self.token: str = token
class TelegramBot(GenericBot):
interface_name = "telegram"
def _init_client(self):
self.client = telegram.Bot(self._telegram_config.token)
self._offset: int = -100
def _call_factory(self) -> typing.Type[Call]:
# noinspection PyMethodParameters
class TelegramCall(Call):
interface_name = "telegram"
interface_name = self.interface_name
interface_obj = self
interface_prefix = "/"
alchemy = self.alchemy
async def reply(call, text: str):
@ -75,9 +53,10 @@ class TelegramBot:
await asyncify(call.channel.send_message, escaped_text, parse_mode="HTML")
async def net_request(call, message: Message, destination: str):
response = await self.network.request(message, destination)
if isinstance(response, RequestError):
raise response.exc
if self.network is None:
raise InvalidConfigError("Royalnet is not enabled on this bot")
response: Message = await self.network.request(message, destination)
response.raise_on_error()
return response
async def get_author(call, error_if_none=False):
@ -94,29 +73,26 @@ class TelegramBot:
result = await asyncify(query.one_or_none)
if result is None and error_if_none:
raise UnregisteredError("Author is not registered")
return result
return TelegramCall
self.TelegramCall = TelegramCall
def __init__(self, *,
telegram_config: TelegramConfig,
royalnet_config: typing.Optional[RoyalnetConfig] = None,
database_config: typing.Optional[DatabaseConfig] = None,
command_prefix: str = "/",
commands: typing.List[typing.Type[Command]] = None,
missing_command: typing.Type[Command] = NullCommand,
error_command: typing.Type[Command] = NullCommand):
super().__init__(royalnet_config=royalnet_config,
database_config=database_config,
command_prefix=command_prefix,
commands=commands,
missing_command=missing_command,
error_command=error_command)
self._telegram_config = telegram_config
self._init_client()
async def run(self):
self.should_run = True
while self.should_run:
# Get the latest 100 updates
try:
last_updates: typing.List[telegram.Update] = await asyncify(self.bot.get_updates, offset=self.offset, timeout=60)
except telegram.error.TimedOut:
continue
# Handle updates
for update in last_updates:
# noinspection PyAsyncCall
asyncio.create_task(self.handle_update(update))
# Recalculate offset
try:
self.offset = last_updates[-1].update_id + 1
except IndexError:
pass
async def handle_update(self, update: telegram.Update):
async def _handle_update(self, update: telegram.Update):
# Skip non-message updates
if update.message is None:
return
@ -130,33 +106,31 @@ class TelegramBot:
return
# Find and clean parameters
command_text, *parameters = text.split(" ")
command_text.replace(f"@{self.bot.username}", "")
# Find the function
try:
command = self.commands[command_text]
except KeyError:
# Skip inexistent _commands
command = self.missing_command
command_text.replace(f"@{self.client.username}", "")
# Call the command
# noinspection PyBroadException
try:
return await self.TelegramCall(message.chat, command, parameters, log,
update=update).run()
except Exception as exc:
try:
return await self.TelegramCall(message.chat, self.error_command, parameters, log,
update=update,
exception_info=sys.exc_info(),
previous_command=command).run()
except Exception as exc2:
log.error(f"Exception in error handler command: {exc2}")
await self.call(command_text, update.message.chat, parameters, update=update)
def generate_botfather_command_string(self):
async def run(self):
while True:
# Get the latest 100 updates
try:
last_updates: typing.List[telegram.Update] = await asyncify(self.client.get_updates, offset=self._offset, timeout=60)
except telegram.error.TimedOut:
continue
# Handle updates
for update in last_updates:
# noinspection PyAsyncCall
loop.create_task(self._handle_update(update))
# Recalculate offset
try:
self._offset = last_updates[-1].update_id + 1
except IndexError:
pass
@property
def botfather_command_string(self) -> str:
string = ""
for command_key in self.commands:
command = self.commands[command_key]
string += f"{command.command_name} - {command.command_description}\n"
return string
async def handle_net_request(self, message: Message):
pass

View file

@ -1,5 +1,4 @@
import logging as _logging
import traceback
from ..utils import Command, Call
from ..error import NoneFoundError, \
TooManyFoundError, \
@ -21,33 +20,28 @@ class ErrorHandlerCommand(Command):
@classmethod
async def common(cls, call: Call):
try:
e_type, e_value, e_tb = call.kwargs["exception_info"]
except InvalidInputError:
await call.reply("⚠️ Questo comando non può essere chiamato da solo.")
return
if e_type == NoneFoundError:
exception: Exception = call.kwargs["exception"]
if isinstance(exception, NoneFoundError):
await call.reply("⚠️ L'elemento richiesto non è stato trovato.")
return
if e_type == TooManyFoundError:
if isinstance(exception, TooManyFoundError):
await call.reply("⚠️ La richiesta effettuata è ambigua, pertanto è stata annullata.")
return
if e_type == UnregisteredError:
if isinstance(exception, UnregisteredError):
await call.reply("⚠️ Devi essere registrato a Royalnet per usare questo comando!")
return
if e_type == UnsupportedError:
if isinstance(exception, UnsupportedError):
await call.reply("⚠️ Il comando richiesto non è disponibile tramite questa interfaccia.")
return
if e_type == InvalidInputError:
if isinstance(exception, InvalidInputError):
command = call.kwargs["previous_command"]
await call.reply(f"⚠️ Sintassi non valida.\nSintassi corretta: [c]{call.interface_prefix}{command.command_name} {command.command_syntax}[/c]")
return
if e_type == InvalidConfigError:
if isinstance(exception, InvalidConfigError):
await call.reply("⚠️ Il bot non è stato configurato correttamente, quindi questo comando non può essere eseguito. L'errore è stato segnalato all'amministratore.")
return
if e_type == ExternalError:
if isinstance(exception, ExternalError):
await call.reply("⚠️ Una risorsa esterna necessaria per l'esecuzione del comando non ha funzionato correttamente, quindi il comando è stato annullato.")
return
await call.reply(f"❌ Eccezione non gestita durante l'esecuzione del comando:\n[b]{e_type.__name__}[/b]\n{e_value}")
formatted_tb: str = '\n'.join(traceback.format_tb(e_tb))
log.error(f"Unhandled exception - {e_type.__name__}: {e_value}\n{formatted_tb}")
await call.reply(f"❌ Eccezione non gestita durante l'esecuzione del comando:\n[b]{exception.__class__.__name__}[/b]\n{exception}")
log.error(f"Unhandled exception - {exception.__class__.__name__}: {exception}")

View file

@ -50,6 +50,5 @@ class PlayCommand(Command):
@classmethod
async def common(cls, call: Call):
guild, url = call.args.match(r"(?:\[(.+)])?\s*(\S+)\s*")
response: typing.Union[RequestSuccessful, RequestError] = await call.net_request(PlayMessage(url, guild), "discord")
response.raise_on_error()
response: RequestSuccessful = await call.net_request(PlayMessage(url, guild), "discord")
await call.reply(f"✅ Richiesta la riproduzione di [c]{url}[/c].")

View file

@ -14,7 +14,7 @@ loop = asyncio.get_event_loop()
class SummonMessage(Message):
def __init__(self, channel_identifier: typing.Union[int, str],
guild_identifier: typing.Optional[typing.Union[int, str]] = None):
self.channel_identifier = channel_identifier
self.channel_name = channel_identifier
self.guild_identifier = guild_identifier
@ -24,7 +24,7 @@ class SummonNH(NetworkHandler):
@classmethod
async def discord(cls, bot: "DiscordBot", message: SummonMessage):
"""Handle a summon Royalnet request. That is, join a voice channel, or move to a different one if that is not possible."""
channel = bot.client.find_channel(message.channel_identifier)
channel = bot.client.find_channel_by_name(message.channel_name)
if not isinstance(channel, discord.VoiceChannel):
raise NoneFoundError("Channel is not a voice channel")
loop.create_task(bot.client.vc_connect_or_move(channel))

View file

@ -22,5 +22,12 @@ class InvalidConfigError(Exception):
"""The bot has not been configured correctly, therefore the command can not function."""
class RoyalnetError(Exception):
"""An error was raised while handling the Royalnet request.
This exception contains the exception that was raised during the handling."""
def __init__(self, exc):
self.exc = exc
class ExternalError(Exception):
"""Something went wrong in a non-Royalnet component and the command execution cannot be completed."""

View file

@ -1,3 +1,7 @@
import traceback
from ..error import RoyalnetError
class Message:
def __repr__(self):
return f"<{self.__class__.__name__}>"
@ -34,7 +38,7 @@ class RequestSuccessful(Message):
class RequestError(Message):
def __init__(self, exc: Exception):
self.exc = exc
self.exc: Exception = exc
def raise_on_error(self):
raise self.exc
raise RoyalnetError(exc=self.exc)