Moved source file from other repo
parent
fda5e1b3d7
commit
e8ee438d65
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
]
|
||||
|
|
|
@ -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):
|
||||
async def stop(self):
|
||||
"""Stop all module daemons and exit."""
|
||||
|
||||
for module in self._loaded_modules:
|
||||
await module.stop()
|
||||
|
||||
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())
|
||||
|
||||
if cmd[0] == self.PREFIX:
|
||||
matched_mods = (
|
||||
mod for mod in self._loaded_modules if mod.match(cmd[1])
|
||||
)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
module = next(matched_mods, None)
|
||||
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)
|
||||
|
|
|
@ -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',
|
||||
]
|
|
@ -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',
|
||||
]
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,10 @@
|
|||
class InvalidCommand(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DuplicateCommand(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MultipleDefaults(Exception):
|
||||
message = 'Multiple default commands detected'
|
|
@ -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)
|
|
@ -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
|
|
@ -1,2 +1 @@
|
|||
from .testmod import TestMod
|
||||
from .mcstat import McStat
|
||||
from .test import TestMod
|
||||
|
|
|
@ -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
10
main.py
|
@ -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,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
|
|
@ -1,6 +1,2 @@
|
|||
discord.py
|
||||
pylint
|
||||
jedi
|
||||
python-dotenv
|
||||
pyyaml
|
||||
mcstatus
|
||||
discord.py~=1.4.1
|
||||
PyYAML~=5.3.1
|
||||
|
|
Reference in New Issue