diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 diff --git a/Makefile b/Makefile old mode 100755 new mode 100644 index 803fb13..457babb --- a/Makefile +++ b/Makefile @@ -1,11 +1,9 @@ # =====CONFIG===== -# File to run when make run is called -MAIN=main.py # Source directory SRC=frank # Directory name of the venv # Don't put spaces in the VENV name, make does not like spaces -# Run make clean first if you do this after already having created a venv +# Run make clean first if you change this after already having created a venv VENV=venv # Docs directory DOCS=docs @@ -13,40 +11,18 @@ DOCS=docs TESTS=tests # Interpreter to create venv with INTERPRETER=python3.8 -# Docker image name:tag -IMAGE='chewingbever/frank:latest' - all: run + # Re-create venv when needed -$(VENV)/bin/activate: requirements.txt +$(VENV)/bin/activate: requirements.txt requirements-dev.txt @ echo "Rebuilding venv..." @ [ ! -e "$(VENV)" ] || rm -rf "$(VENV)" @ "$(INTERPRETER)" -m venv "$(VENV)" - @ "$(VENV)/bin/pip" install -r requirements.txt + @ "$(VENV)/bin/pip" install -r requirements.txt -r requirements-dev.txt -build: $(VENV)/bin/activate - -# Run script -run: build - @ "$(VENV)/bin/python" "$(MAIN)" - -# =====DOCKER===== -# Build docker image -dbuild: docker/Dockerfile - @ docker build -f docker/Dockerfile -t $(IMAGE) . - -# Run docker -drun: dbuild docker/docker-compose.yml - @ docker-compose -f docker/docker-compose.yml up - -# run docker as daemon -drund: dbuild docker/docker-compose.yml - @ docker-compose -f docker/docker-compose.yml up -d - -dpush: dbuild - @ docker push $(IMAGE) +build-venv: $(VENV)/bin/activate # =====CLEANING===== clean: clean-venv clean-cache clean-docs @@ -69,7 +45,7 @@ clean-docs: # =====DOCS===== -$(VENV)/bin/sphinx-build: build +$(VENV)/bin/sphinx-build: build-venv @ echo "Installing sphinx..." @ "$(VENV)/bin/pip" install --quiet sphinx @@ -79,11 +55,11 @@ docs: $(VENV)/bin/sphinx-build # =====TESTS===== -$(VENV)/bin/pytest: build +$(VENV)/bin/pytest: build-venv @ echo "Installing pytest..." @ "$(VENV)/bin/pip" install --quiet pytest -test: pytest.ini build +test: pytest.ini $(VENV)/bin/pytest @ "$(VENV)/bin/pytest" --color=yes @@ -98,5 +74,5 @@ package: README.md LICENSE setup.py test # Publish will also come here someday -.PHONY: all run clean clean-venv clean-cache clean-docs test package docs \ - build dbuild drun dpush drund +.PHONY: all clean clean-venv clean-cache clean-docs test package docs \ + build-venv run-venv diff --git a/frank/__init__.py b/frank/__init__.py index 3d6e829..0468a36 100644 --- a/frank/__init__.py +++ b/frank/__init__.py @@ -1,2 +1,18 @@ from .frank import Frank -from .module import Module +from .module import ( + Module, command, Command, default, Default, daemon, Daemon, + regex_command, RegexCommand, +) + +__all__ = [ + 'Frank', + 'Module', + 'command', + 'Command', + 'default', + 'Default', + 'daemon', + 'Daemon', + 'regex_command', + 'RegexCommand', +] diff --git a/frank/frank.py b/frank/frank.py index 4095144..516e509 100644 --- a/frank/frank.py +++ b/frank/frank.py @@ -1,27 +1,53 @@ +# =====IMPORTS===== +# Future imports +from __future__ import annotations + +# Built-in imports import shlex -from typing import List -import discord + +# Third-party imports import yaml +import discord + +# Typing imports +from typing import TYPE_CHECKING, List +if TYPE_CHECKING: + # Own imports + from .module import Module + from discord import Message class Frank(discord.Client): - PREFIX = "fr" + """ + Main class of the bot; works by adding modules, which all define + their own behavior. + """ + + PREFIX = 'fr' + """Prefix to use Frank inside Discord.""" + + def __init__(self, modules: List[Module], config_file: str = 'frank.yaml'): + """ + Args: + modules: modules to load + config_file: path to yaml config file; ignored if non-existent + """ - def __init__(self, modules: List["Module"], - config_file: str = "frank.yaml"): super().__init__() self._modules = modules self._loaded_modules = [] try: - with open(config_file, "r") as f: + with open(config_file, 'r') as f: self._config = yaml.load(f, Loader=yaml.FullLoader) except FileNotFoundError: self._config = None async def on_ready(self): - print("Connected") + """Runs when the bot has succesfully connected to Discord""" + + print('Connected') # Startup all modules for module in self._modules: @@ -31,20 +57,36 @@ class Frank(discord.Client): else: loaded = module(self) - await loaded.start() + await loaded._start() self._loaded_modules.append(loaded) - print("All modules loaded") + print('All modules loaded') - async def on_message(self, message: str): - cmd = shlex.split(message.content.strip()) + async def stop(self): + """Stop all module daemons and exit.""" - if cmd[0] == self.PREFIX: - matched_mods = ( - mod for mod in self._loaded_modules if mod.match(cmd[1]) - ) + for module in self._loaded_modules: + await module.stop() - module = next(matched_mods, None) + async def on_message(self, message: Message): + """ + Runs when a new message is sent in the Discord channel. + + Args: + message: object representing the received message; see + https://discordpy.readthedocs.io/en/latest/api.html#message + """ + + try: + cmd = shlex.split(message.content.strip()) + + except ValueError: + return + + if cmd and cmd[0] == self.PREFIX: + module = next((mod for mod in self._loaded_modules + if mod.match(cmd[1])), None) if module: - await module.command(cmd[2:]) + await module(cmd=cmd[2:], author=message.author, + channel=message.channel, mid=message.id) diff --git a/frank/module/__init__.py b/frank/module/__init__.py new file mode 100644 index 0000000..322a87d --- /dev/null +++ b/frank/module/__init__.py @@ -0,0 +1,18 @@ +from .module import Module +from .decorators import ( + command, Command, default, Default, daemon, Daemon, regex_command, + RegexCommand, +) + + +__all__ = [ + 'Module', + 'command', + 'Command', + 'default', + 'Default', + 'daemon', + 'Daemon', + 'regex_command', + 'RegexCommand', +] diff --git a/frank/module/decorators/__init__.py b/frank/module/decorators/__init__.py new file mode 100644 index 0000000..1c8ce8e --- /dev/null +++ b/frank/module/decorators/__init__.py @@ -0,0 +1,13 @@ +from .classes import Command, RegexCommand, Daemon, Default +from .functions import command, regex_command, daemon, default + +__all__ = [ + 'command', + 'Command', + 'regex_command', + 'RegexCommand', + 'default', + 'Default', + 'daemon', + 'Daemon', +] diff --git a/frank/module/decorators/classes.py b/frank/module/decorators/classes.py new file mode 100644 index 0000000..3e93be1 --- /dev/null +++ b/frank/module/decorators/classes.py @@ -0,0 +1,120 @@ +# =====IMPORTS===== +# Future imports +from __future__ import annotations + +# Built-in imports +import re + + +class Simple: + """ + Acts as a base class for all other types; behaves like the given + function + """ + + def __init__(self, func: callable): + """ + Args: + func: function to mimic + """ + + self.func = func + + def __call__(self, *args, **kwargs): + """ + All this call does is call the wrapped function. Because we overwrote + __get__, we can pass self to the function, making it behave as a class + method of the instance calling it. + """ + + return self.func.__call__(self._obj, *args, **kwargs) + + def __get__(self, instance, owner) -> Simple: + """ + We use __get__ to get the class calling the function. This allows us to + pass 'self' to the wrapped function, effectively making this class + fully behave as a class method. + + Args: + instance: instance calling the function + owner: type of the function + """ + + self._cls = owner + self._obj = instance + + return self + + +class Command(Simple): + """ + Represents a command of the module. + """ + + def __init__(self, func: callable, cmd: str, help_str: str = None): + """ + Args: + func: function to wrap + cmd: keyword used to call this function + help_str: short description of the command + """ + + super().__init__(func) + + self.cmd = cmd + self.help_str = help_str + + def match(self, prefix: str) -> bool: + """ + Returns wether the command matches the given prefix. + + Args: + prefix: string to match own prefix against + """ + + return self.cmd == prefix + + +class RegexCommand(Command): + """ + A subclass of Command that can use a regex pattern instead of a fixed + prefix. + """ + + def match(self, prefix: str) -> bool: + """ + Returns wether the regex pattern matches the given prefix. + + Args: + prefix: string to match pattern against; Pattern must match entire + prefix + """ + + return bool(re.fullmatch(self.cmd, prefix)) + + +class Daemon(Simple): + """ + Represents a daemon. Currently, it's only used as its own type, but writing + it this way allows us to easily expand upon its functionality later. + """ + + pass + + +class Default(Simple): + """ + Represents a default command (a.k.a. when the module is called without a + command. + """ + + def __init__(self, func: callable, help_str: str = None): + """ + Args: + func: function to wrap + help_str: short description of the default command + """ + + super().__init__(func) + + self.help_str = help_str diff --git a/frank/module/decorators/functions.py b/frank/module/decorators/functions.py new file mode 100644 index 0000000..205cd87 --- /dev/null +++ b/frank/module/decorators/functions.py @@ -0,0 +1,58 @@ +# =====IMPORTS===== +# Own imports +from .classes import Command, RegexCommand, Daemon, Default + + +def command(cmd, help_str: str = None) -> callable: + """ + Converts a method into a command by replacing it with a Command object. + + Args: + cmd: keyword used to call this function + help_str: short description of the command + """ + + def inner(func): + return Command(func, cmd, help_str) + + return inner + + +def regex_command(pattern: str, help_str: str = None) -> callable: + """ + Converts the method into a RegexCommand. + + Args: + pattern: regex pattern to match command with + help_str: short description of the command + """ + + def inner(func): + return RegexCommand(func, pattern, help_str) + + return inner + + +def daemon() -> callable: + """ + Converts the method into a Daemon, which will then be run when the module + is started. + """ + + def inner(func): + return Daemon(func) + + return inner + + +# TODO: make sure the default is unique +def default(help_str: str = None) -> callable: + """ + Converts the method into the Default method, making it the default command + when the module is run without a command. + """ + + def inner(func): + return Default(func, help_str) + + return inner diff --git a/frank/module/exceptions.py b/frank/module/exceptions.py new file mode 100644 index 0000000..4502971 --- /dev/null +++ b/frank/module/exceptions.py @@ -0,0 +1,10 @@ +class InvalidCommand(Exception): + pass + + +class DuplicateCommand(Exception): + pass + + +class MultipleDefaults(Exception): + message = 'Multiple default commands detected' diff --git a/frank/module/meta.py b/frank/module/meta.py new file mode 100644 index 0000000..1d906a4 --- /dev/null +++ b/frank/module/meta.py @@ -0,0 +1,45 @@ +# =====IMPORTS===== +# Future imports +from __future__ import annotations + +# Built-in imports +from functools import cached_property + +# Own imports +from .decorators import Command, Daemon, Default + +# Typing imports +from typing import TYPE_CHECKING +if TYPE_CHECKING: + # Built-in imports + from typing import List, Any + + +class ModuleMeta: + def _filter_attrs(self, condition: callable[[Any], bool]) -> List[Any]: + illegal_names = ['commands', 'daemons', 'default'] + + output = [] + + for attr in filter(lambda x: x not in illegal_names, dir(self)): + value = getattr(self, attr) + + if condition(value): + output.append(value) + + return output + + @cached_property + def commands(self) -> List[Command]: + # This also matches RegexCommand objects + # TODO: sort this to put RegexCommand's at the back + return self._filter_attrs(lambda val: isinstance(val, Command)) + + @cached_property + def daemons(self) -> List[Daemon]: + return self._filter_attrs(lambda val: isinstance(val, Daemon)) + + @cached_property + def default(self) -> Default: + return next(iter(self._filter_attrs( + lambda val: isinstance(val, Default))), None) diff --git a/frank/module/module.py b/frank/module/module.py new file mode 100644 index 0000000..2b3e265 --- /dev/null +++ b/frank/module/module.py @@ -0,0 +1,125 @@ +# =====IMPORTS===== +# Future imports +from __future__ import annotations + +# Built-in imports +import asyncio + +# Own imports +from .exceptions import InvalidCommand +from .meta import ModuleMeta +from .decorators import RegexCommand + +# Typing imports +from typing import TYPE_CHECKING +if TYPE_CHECKING: + # Built-in imports + from typing import List, Dict + + # Third-party imports + from discord.abc import User, Messageable + + # Own imports + from suzybot.frank import Frank + + +class Module(ModuleMeta): + """Base class for modules; all custom modules should inherit from this.""" + + PREFIX = [] + """Prefix to activate this module.""" + + NAME = '' + """The name is used in various places, such as the config file and the + help function.""" + + HELP = '' + """Short description of the module to use in the help function.""" + + def __init__(self, client: Frank, config: Dict = None): + """ + Args: + client: client using this module; used to communicate. + config: dict containing the config for this module (Frank client + reads this from the config file). + """ + super().__init__() + + self._client = client + self._config = config + + self._tasks = [] + + def pre_start(self): + """ + Overwrite this function to run code (e.g. add variables...) before + starting the daemons. + """ + + pass + + async def _start(self): + """Start up defined daemons for this module.""" + + self.pre_start() + + for daemon in self.daemons: # pylint: disable=no-member + task = asyncio.create_task(daemon()) + self._tasks.append(task) + + async def stop(self): + """Stop all tasks for this module.""" + + for task in self._tasks: + task.cancel() + + async def __call__(self, cmd: List[str], author: User, + channel: Messageable, mid: int): + """ + Execute the command, if found. + + Args: + cmd: list of command arguments; if empty, default command is used + author: author of message + channel: channel the message was sent in + mid: message id + """ + + if cmd: + func = next((func for func in self.commands + if func.match(cmd[0])), None) + + if func: + # A RegexCommand can use the prefix, as it's not a fixed string + if isinstance(func, RegexCommand): + await func(prefix=cmd[0], cmd=cmd[1:], author=author, + channel=channel, mid=mid) + + else: + await func(cmd=cmd[1:], author=author, channel=channel, + mid=mid) + + else: + raise InvalidCommand(f'Unknown command: {cmd}') + + elif self.default: + await self.default(author=author, channel=channel, mid=mid) + + @classmethod + def match(cls, prefix: str) -> bool: + """ + Checks wether the given prefix matches the module. + + Args: + prefix: prefix to check + """ + + if cls.PREFIX: + if isinstance(cls.PREFIX, list): + return prefix in cls.PREFIX + + else: + return prefix == cls.PREFIX + + else: + return False diff --git a/frank/modules/__init__.py b/frank/modules/__init__.py index c79d5e8..5374cbc 100644 --- a/frank/modules/__init__.py +++ b/frank/modules/__init__.py @@ -1,2 +1 @@ -from .testmod import TestMod -from .mcstat import McStat +from .test import TestMod diff --git a/frank/modules/mcstat.py b/frank/modules/mcstat.py deleted file mode 100644 index 37aae9d..0000000 --- a/frank/modules/mcstat.py +++ /dev/null @@ -1,32 +0,0 @@ -from .. import Module -from mcstatus import MinecraftServer - - -class McStat(Module): - PREFIX = "mc" - NAME = "mcstat" - - async def command(self, cmd): - if cmd[0] == "online": - address = self._config["domain"] - port = self._config.get("port") - - if port: - address += ":" + str(port) - - server = MinecraftServer.lookup(address) - status = server.status() - - if status.players.sample is not None: - players = [player.name for player in status.players.sample] - - else: - players = None - - channel = self._client.get_channel(self._config["channel_id"]) - - if players: - await channel.send(f'Currently online: {",".join(players)}') - - else: - await channel.send("No one is here bro") diff --git a/frank/modules/testmod.py b/frank/modules/test.py similarity index 100% rename from frank/modules/testmod.py rename to frank/modules/test.py diff --git a/main.py b/main.py deleted file mode 100644 index 2f2dad6..0000000 --- a/main.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -from dotenv import load_dotenv -from frank.modules import TestMod, McStat -from frank import Frank - - -if __name__ == "__main__": - load_dotenv() - client = Frank([TestMod, McStat]) - client.run(os.getenv('DISCORD_TOKEN')) diff --git a/pytest.ini b/pytest.ini old mode 100755 new mode 100644 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..24f558a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +jedi~=0.17.2 +flake8~=3.8.3 +flake8-bugbear~=20.1.4 +flake8-builtins~=1.5.3 +flake8-commas~=2.0.0 +flake8-comprehensions~=3.2.3 +flake8-eradicate~=0.4.0 +flake8-quotes~=3.2.0 +flake8-variables-names~=0.0.3 diff --git a/requirements.txt b/requirements.txt index 5afd50a..19c9bf4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,2 @@ -discord.py -pylint -jedi -python-dotenv -pyyaml -mcstatus +discord.py~=1.4.1 +PyYAML~=5.3.1 diff --git a/setup.py b/setup.py old mode 100755 new mode 100644