Moved source file from other repo

master
Jef Roosens 2020-08-25 17:07:27 +02:00
parent fda5e1b3d7
commit e8ee438d65
19 changed files with 487 additions and 102 deletions

0
.gitignore vendored 100755 → 100644
View File

44
Makefile 100755 → 100644
View File

@ -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

View File

@ -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',
]

View File

@ -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)

View File

@ -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',
]

View File

@ -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',
]

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,10 @@
class InvalidCommand(Exception):
pass
class DuplicateCommand(Exception):
pass
class MultipleDefaults(Exception):
message = 'Multiple default commands detected'

View File

@ -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)

View File

@ -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

View File

@ -1,2 +1 @@
from .testmod import TestMod
from .mcstat import McStat
from .test import TestMod

View File

@ -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")

10
main.py
View File

@ -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'))

0
pytest.ini 100755 → 100644
View File

View File

@ -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

View File

@ -1,6 +1,2 @@
discord.py
pylint
jedi
python-dotenv
pyyaml
mcstatus
discord.py~=1.4.1
PyYAML~=5.3.1

0
setup.py 100755 → 100644
View File