diff --git a/.gitignore b/.gitignore index 0929d02..41927af 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ # import __pycache__ - +*.egg-info # folders _img_dices @@ -18,4 +18,9 @@ _img_dices .idea # env -.env \ No newline at end of file +.env + +# test +.pytest_cache +*.html +.env diff --git a/Dockerfile.armv7l b/Dockerfile.armv7l index 9fa5c1c..2f924ec 100644 --- a/Dockerfile.armv7l +++ b/Dockerfile.armv7l @@ -1,8 +1,10 @@ FROM arm32v7/python:3.10-buster as arch_armv7l +RUN useradd -ms /bin/bash bot +USER bot ENV DISCORD_TOKEN=${DISCORD_TOKEN} WORKDIR /ramoloss COPY . . -RUN apt-get update && apt-get install libatlas-base-dev -y \ - && pip install --no-cache-dir -r requirements.txt +RUN apt-get update && apt-get install \ + libatlas-base-dev -y && pip install --no-cache-dir -e /bot CMD [ "python", "main.py"] diff --git a/Dockerfile.x86_64 b/Dockerfile.x86_64 index b1be3a4..9db87c2 100644 --- a/Dockerfile.x86_64 +++ b/Dockerfile.x86_64 @@ -1,8 +1,9 @@ FROM python:3.10-buster as x86_64 +RUN useradd -ms /bin/bash bot +USER bot ENV DISCORD_TOKEN=${DISCORD_TOKEN} WORKDIR /ramoloss COPY . . -RUN apt-get update && pip install --no-cache-dir \ - -r requirements.txt +RUN apt-get update && pip install --no-cache-dir -e bot/ CMD [ "python", "main.py"] diff --git a/Makefile b/Makefile index 0d6028a..098cee8 100644 --- a/Makefile +++ b/Makefile @@ -3,28 +3,30 @@ SHELL = /bin/bash NAME ?= ramoloss ARCH ?= $(shell uname -m) VERSION ?= $(shell git describe --tag --abbrev=0) +SERVICES := bot db log # docker-compose DC := $(shell type -p docker-compose) DC_BUILD_ARGS := --pull --force-rm DC_RUN_ARGS := -d --no-build DC_FILE := docker-compose.yml +DC_CONFIG_ARGS := -q +PLATFORM ?= linux/amd64 # docker #REGISTRY ?= docker.io REGISTRY ?= ghcr.io REPOSITORY ?= ramoloss +REGISTRY_USERNAME ?= toto # image -IMAGE_bot=${NAME}-bot:${VERSION} -IMAGE_REGISTRY_bot=${REGISTRY}/${REGISTRY_USERNAME}/${IMAGE_bot} - +IMAGES = $(foreach srv, $(SERVICES), ${NAME}-${srv}:${VERSION}) +IMAGES_REGISTRY=$(foreach im, $(IMAGE), ${REGISTRY}/${REGISTRY_USERNAME}/${im}) export - all: - @echo "Usage: NAME=ramoloss make deploy | build | \ + @echo "Usage: VERSION=latest make deploy | build | \ up | down | test | check | push | pull " @@ -33,15 +35,12 @@ check-var-%: @: $(if $(value $*),,$(error $* is undefined)) @echo ${$*} +# for see configs use: `make DC_CONFIG_ARGS="" check-config` check-config: - ${DC} -f ${DC_FILE} config - -check-config-quiet: - ${DC} -f ${DC_FILE} config -q + ${DC} -f ${DC_FILE} config ${DC_CONFIG_ARGS} # build all or one service -build: check-config-quiet - echo ${VERSION} +build: check-config ${DC} -f ${DC_FILE} build ${DC_BUILD_ARGS} build-%: @@ -49,13 +48,13 @@ build-%: ${DC} -f ${DC_FILE} build ${DC_BUILD_ARGS} $* # up all or one service -up: check-config-quiet +up: check-config @if [ -z "${DISCORD_TOKEN}" ] ; \ then echo "ERROR: DISCORD_TOKEN \ not defined" ; exit 1 ; fi ${DC} -f ${DC_FILE} up ${DC_RUN_ARGS} -up-%: check-config-quiet +up-%: check-config ${DC} -f ${DC_FILE} up ${DC_RUN_ARGS} $* # down all or one service @@ -65,22 +64,25 @@ down: down-%: ${DC} -f ${DC_FILE} down $* -# test -test: test-container +# test container and app +test: test-container test-bot test-%: @echo "# test $*" bash tests/test-$*.sh -# push +pytest: + docker exec + +# push push: push-bot push-%: @if [ -z "${REGISTRY}" -a -z "${REGISTRY_USERNAME}" ] ; \ then echo "ERROR: REGISTRY and REGISTRY_USERNAME \ not defined" ; exit 1 ; fi - docker tag ${IMAGE_$*} ${IMAGE_REGISTRY_$*} - docker push ${IMAGE_REGISTRY_$*} + docker tag ${NAME}-$*:${VERSION} ${REGISTRY}/${REGISTRY_LOGIN}/${NAME}-$*:${VERSION} + docker push ${REGISTRY}/${REGISTRY_LOGIN}/${NAME}-$*:${VERSION} pull: pull-bot @@ -88,4 +90,10 @@ pull-%: @if [ -n "${REGISTRY_TOKEN}" -a -n "${REGISTRY_LOGIN}" ] ;\ then echo ${REGISTRY_TOKEN} | docker login ${REGISTRY} \ --username ${REGISTRY_LOGIN} --password-stdin ; fi - docker pull ${REGISTRY}/${REGISTRY_LOGIN}/${NAME}-$*:latest \ No newline at end of file + docker pull ${REGISTRY}/${REGISTRY_LOGIN}/${NAME}-$*:${VERSION} + docker tag ${REGISTRY}/${REGISTRY_LOGIN}/${NAME}-$*:${VERSION} ${NAME}-$*:${VERSION} + + +deploy: VERSION=latest +deploy: pull up + diff --git a/bot/__init__.py b/bot/__init__.py index 1050b9e..9e0457a 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1 +1,3 @@ -from ._bot import Ramoloss \ No newline at end of file +from ._bot import Ramoloss + +__all__ = ['Ramoloss'] diff --git a/bot/_bot.py b/bot/_bot.py index b01c829..378521b 100644 --- a/bot/_bot.py +++ b/bot/_bot.py @@ -1,19 +1,23 @@ +import os from discord.ext.commands import Bot class Ramoloss(Bot): - def __init__(self, config, token): + def __init__(self, config, token, **kwargs): self.config = config self.discord_token = token + os.environ["command_prefix"] = self.config["command_prefix"] super().__init__( command_prefix=self.config["command_prefix"], - description=self.config["description"] - ) + description=self.config["description"], + **kwargs) + for extension in self.config["extensions"]: - self.load_extension(extension) + self.load_extension('cogs.' + extension) async def on_ready(self): - print(f'Logged in as {self.user} with extensions: \n{" ".join(self.extensions).replace("cogs.", "")}') + print((f'Logged in as {self.user} with extensions:' + f'\n{" ".join(self.extensions).replace("cogs.", "")}')) def run(self, *args, **kwargs): super().run(self.discord_token, reconnect=True) diff --git a/bot/cogs/dev/owner.py b/bot/cogs/dev/owner.py new file mode 100644 index 0000000..5955b9d --- /dev/null +++ b/bot/cogs/dev/owner.py @@ -0,0 +1,67 @@ +from discord.ext import commands + + +class Owner(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.path = 'cogs.' + + @commands.command(name="load", hidden=True) + @commands.has_permissions() + @commands.is_owner() + async def load(self, ctx, *, cog: str = None): + cog = self.parse_args(cog) + for module in cog: + self.bot.load_extension(self.path + module) + await ctx.send("**`SUCCESS`**") + + @commands.command(name="unload", hidden=True) + @commands.has_permissions() + @commands.is_owner() + async def unload(self, ctx, *, cog: str = None): + cog = self.parse_args(cog) + for module in cog: + self.bot.unload_extension(self.path + module) + await ctx.send("**`SUCCESS`**") + + @commands.command(name="reload", hidden=True) + @commands.has_permissions() + @commands.is_owner() + async def reload(self, ctx, *, cog: str = None): + cog = self.parse_args(cog) + for module in cog: + self.bot.unload_extension(self.path + module) + self.bot.load_extension(self.path + module) + await ctx.send("**`SUCCESS`**") + + def parse_args(self, cog): + if cog is None: + cog = [cg for cg in + self.bot.config["extensions"] + if 'user' in cg] + return cog + + if isinstance(cog, str): + if ' ' in cog: + raise ValueError('One module at a time!') + if cog in ('owner', 'settings'): + cog = 'dev.' + cog + else: + if 'user' not in cog: + cog = 'user.' + cog + cog = [cog] + return cog + + @reload.error + @load.error + @unload.error + async def error(self, ctx, error): + if isinstance(error, (commands.NotOwner, + commands.errors.CommandInvokeError)): + await ctx.send(f"```ERROR: {error.__cause__}```") + else: + print(error) + + +def setup(bot): + bot.add_cog(Owner(bot)) diff --git a/cogs/settings.py b/bot/cogs/dev/settings.py similarity index 99% rename from cogs/settings.py rename to bot/cogs/dev/settings.py index 2a0148a..eb1612c 100644 --- a/cogs/settings.py +++ b/bot/cogs/dev/settings.py @@ -9,7 +9,6 @@ def __init__(self, bot): async def say_hello(self, ctx): await ctx.channel.send(f'Hello {ctx.author.name}') - @commands.has_permissions(administrator=True) @commands.command(name='set_prefix') async def set_prefix(self, ctx, *, new_prefix: str): @@ -22,6 +21,5 @@ async def pref_error(self, ctx, error): await ctx.send(error) - def setup(bot): bot.add_cog(Settings(bot)) diff --git a/cogs/dice.py b/bot/cogs/user/dice.py similarity index 99% rename from cogs/dice.py rename to bot/cogs/user/dice.py index 9e3a826..757fee5 100644 --- a/cogs/dice.py +++ b/bot/cogs/user/dice.py @@ -103,7 +103,6 @@ def _random_de(low=1, high=6, size=1): size = int(size) return np.random.randint(low=low, high=high + 1, size=size) - def _operator(self, values): if len(values) < 3: return values diff --git a/cogs/john.py b/bot/cogs/user/john.py similarity index 99% rename from cogs/john.py rename to bot/cogs/user/john.py index 5b76a11..76fa983 100644 --- a/cogs/john.py +++ b/bot/cogs/user/john.py @@ -22,5 +22,6 @@ def is_john(message): return True return False + def setup(bot): bot.add_cog(John(bot)) diff --git a/cogs/poll.py b/bot/cogs/user/poll.py similarity index 100% rename from cogs/poll.py rename to bot/cogs/user/poll.py diff --git a/cogs/ufd.py b/bot/cogs/user/ufd.py similarity index 75% rename from cogs/ufd.py rename to bot/cogs/user/ufd.py index 5ea2f3b..b98346e 100644 --- a/cogs/ufd.py +++ b/bot/cogs/user/ufd.py @@ -1,6 +1,7 @@ from textwrap import TextWrapper import discord from discord.ext import commands +from discord.ext.commands.errors import CommandInvokeError from cogs.utils import ( UltimateFD, @@ -20,7 +21,6 @@ def __init__(self, bot): self.bot = bot self.url = ufd.url self.get_all_characters = ufd.get_all_characters - self.ref_atk = ufd.REF_ATK self.n_args = None self.command = None self.args = None @@ -28,15 +28,19 @@ def __init__(self, bot): @commands.cooldown(rate=5, per=30, type=commands.BucketType.user) @commands.command(name="ufd") async def ufd(self, ctx, command=None, *args): - + """ + Command UFD for get information about moves for a character in + SSBU i.e "ufd wario nair fair" + """ self.n_args = len(args) self.command = command self.args = iter(args) match self.command: case None | 'help': - help_message = self.help(title=TITLE_UFD, - description_command=DESCRIPTION_COMMAND_UFD) + help_message = self.help( + title=TITLE_UFD, + description_command=DESCRIPTION_COMMAND_UFD) await ctx.channel.send(embed=help_message) return @@ -55,34 +59,38 @@ async def ufd(self, ctx, command=None, *args): await ctx.channel.send(embed=embed_m) case 'index': - pass + raise NotImplementedError case _: - await ctx.channel.send(( + raise ValueError(( "Unrecognized command: make sure you're choosing " "between 'char', 'move', 'index'")) - return case _: if not self.n_args: - await ctx.channel.send('Must choice a move in "ufd list moves"') + raise ValueError( + 'Must choice a move. Availables moves with "ufd list moves"') - try: - char = UltimateFD(character=command, - moves=self.args, - get_hitbox=True, - args_stats=None) - except (ValueError, KeyError) as error: - await ctx.channel.send(f"{error}") - return + char = UltimateFD(character=command, + moves=self.args, + get_hitbox=True, + args_stats=None) for move, statistics in char.stats.items(): - embed, hitbox = self.create_stats(move=move, statistics=statistics) + embed, hitbox = self.create_stats( + move=move, statistics=statistics) await ctx.channel.send(embed=embed) if hitbox is not None: await ctx.channel.send(hitbox) + @ufd.error + async def error(self, ctx, error): + if isinstance(error, (commands.CommandOnCooldown, CommandInvokeError)): + if isinstance(error, CommandInvokeError): + error = error.__cause__ + await ctx.send(f'```ERROR: {error.__cause__}```') + print(error) def show_list(self, selection): """ @@ -93,16 +101,8 @@ def show_list(self, selection): names = [name for name in all_characters if selection in name] return names - - @ufd.error - async def ufd_error(self, ctx, error): - if isinstance(error, commands.CommandOnCooldown): - await ctx.send(error) - else: - print(error) - - - def show_wrap_message(self, list_to_out, title, wrap_at=1000): + @staticmethod + def show_wrap_message(list_to_out, title, wrap_at=1000): """ Show embed message in discord channel with split at wrap_at (default: 1000 chars) @@ -113,7 +113,6 @@ def show_wrap_message(self, list_to_out, title, wrap_at=1000): replace_whitespace=False).wrap(output) return [discord.Embed(title=title, description=m) for m in send_messages] - def select_subcommand(self): """ Select subcommand, default 'char' command @@ -123,7 +122,7 @@ def select_subcommand(self): return next(self.args) return "char" - def select_typecommand(self, choice: str ='char', selection=None): + def select_typecommand(self, choice: str = 'char', selection=None): """ Select type command - if type is 'char' return embed @@ -141,14 +140,13 @@ def select_typecommand(self, choice: str ='char', selection=None): title = "Liste des personnages" case 'move': list_out = [f"**{ref}** ({move.title()})" - for ref, move in REF_ATK.items()] + for ref, move in REF_ATK.items()] title = "Liste des mouvements" list_embed = self.show_wrap_message(list_to_out=list_out, title=title) return list_embed - def create_stats(self, move, statistics, hitbox=None): """ Create stats embed with hitbox if available @@ -156,8 +154,8 @@ def create_stats(self, move, statistics, hitbox=None): title = f"**{self.command.title().replace('_', ' ')} – {move.title()}**" embed = discord.Embed(title=title, - color=0x03F8FC, - url=self.url + self.command) + color=0x03F8FC, + url=self.url + self.command) for stats, amount in statistics.items(): if stats == "hitbox": hitbox = amount @@ -167,5 +165,6 @@ def create_stats(self, move, statistics, hitbox=None): inline=True) return embed, hitbox + def setup(bot): bot.add_cog(UFD(bot)) diff --git a/bot/cogs/utils/__init__.py b/bot/cogs/utils/__init__.py new file mode 100644 index 0000000..028ecb7 --- /dev/null +++ b/bot/cogs/utils/__init__.py @@ -0,0 +1,13 @@ +from ._ufd import (REF_ATK, DEFAULT_EXCLUDE_OVERALL_STATS, + DEFAULT_STATS, UltimateFD) +from ._poll import EMOJI +from ._help import (HelperCommand, TITLE_UFD, DESCRIPTION_COMMAND_UFD, + TITLE_POLL, DESCRIPTION_COMMAND_POLL, TITLE_DICE, + DESCRIPTION_COMMAND_DICE) +from ._args import ParseArgs + +__all__ = ["REF_ATK", "DEFAULT_EXCLUDE_OVERALL_STATS", + "DEFAULT_STATS", "UltimateFD", + "EMOJI", "HelperCommand", "TITLE_UFD", "DESCRIPTION_COMMAND_UFD", + "TITLE_POLL", "DESCRIPTION_COMMAND_POLL", "TITLE_DICE", + "DESCRIPTION_COMMAND_DICE", "ParseArgs"] diff --git a/cogs/utils/_args.py b/bot/cogs/utils/_args.py similarity index 100% rename from cogs/utils/_args.py rename to bot/cogs/utils/_args.py diff --git a/cogs/utils/_dice.py b/bot/cogs/utils/_dice.py similarity index 80% rename from cogs/utils/_dice.py rename to bot/cogs/utils/_dice.py index c286124..dad32e0 100644 --- a/cogs/utils/_dice.py +++ b/bot/cogs/utils/_dice.py @@ -19,9 +19,15 @@ class DiceGenerator: """ Dice Image Generator """ - def __init__(self, pt1=(30, 70), pt2=(70, 30), - thinkness=5, shape=(100, 100, 3), - xy=(50, 52), crop=(200, 200), directory='_img_dices'): + + def __init__(self, + pt1=(30, 70), + pt2=(70, 30), + thinkness=5, + shape=(100, 100, 3), + xy=(50, 52), + crop=(200, 200), + directory='_img_dices'): """ Usage example: TODO @@ -34,10 +40,12 @@ def __init__(self, pt1=(30, 70), pt2=(70, 30), self.crop = crop self.directory = self.get_path(directory) - - def create_lauch_dice(self, inputs_dice: list, colors='green', line_return: int = 3, save=True): + def create_lauch_dice( + self, inputs_dice: list, colors='green', line_return: int = 3, + save=True): """ - Draw a dice or multiple dices side by side with a value number and a color. + Draw a dice or multiple dices side by side with + a value number and a color. Params: ------ @@ -58,7 +66,8 @@ def create_lauch_dice(self, inputs_dice: list, colors='green', line_return: int list_colors = self.convert_colors(colors, inputs_dice) if len(list_colors) != len(inputs_dice): - raise ValueError('Colors and dice numbers must be in the same size') + raise ValueError( + 'Colors and dice numbers must be in the same size') for (n, i), c in zip(enumerate(inputs_dice), list_colors): if (n % line_return == 0) and (n != 0): @@ -68,16 +77,17 @@ def create_lauch_dice(self, inputs_dice: list, colors='green', line_return: int list_img = [] img = self.create_image_dice(value=i, - size=50, - color=c, - border=1, save=False) + size=50, + color=c, + border=1, save=False) list_img.append(img) if n == last: line_img = self.create_side_by_side( list_img, how='horizontal', save=False) list_lines.append(line_img) - output_img = self.create_side_by_side(list_lines, how='vertical', save=False) + output_img = self.create_side_by_side( + list_lines, how='vertical', save=False) if save: path = os.path.join(self.directory, 'out.png') output_img.save(path) @@ -109,8 +119,10 @@ def create_image_dice(self, value, size=50, size = self.reformat_size(value, size) img = self.write_white_img(self.shape) rect_border = cv2.rectangle(img, - (self.pt1[0] - border, self.pt1[1] + border), - (self.pt2[0] + border, self.pt2[1] - border), + (self.pt1[0] - border, + self.pt1[1] + border), + (self.pt2[0] + border, + self.pt2[1] - border), color_b, self.thinkness) @@ -123,8 +135,8 @@ def create_image_dice(self, value, size=50, fontcolor = 'white' plt.imshow(rect_center) - plt.annotate(value, xy=self.xy, size=size, ha='center', color=fontcolor, - va='center', fontname=font, weight=weight) + plt.annotate(value, xy=self.xy, size=size, ha='center', + color=fontcolor, va='center', fontname=font, weight=weight) plt.axis('off') plt.savefig(path) plt.close() @@ -177,7 +189,8 @@ def crop_center(pil_img, crop_width, crop_height, slide_x=0, slide_y=15): (img_height + crop_height) // 2)) def create_side_by_side( - self, list_img: list, name='line', how='horizontal', save=False, saved_images=False): + self, list_img: list, name='line', how='horizontal', save=False, + saved_images=False): """Concatenate multiple PIL images verticaly or horizontaly""" if saved_images: @@ -190,7 +203,8 @@ def create_side_by_side( total_width = sum(widths) max_height = max(heights) - new_im = Image.new('RGB', size=(total_width, max_height), color=(255, 255, 255)) + new_im = Image.new('RGB', size=( + total_width, max_height), color=(255, 255, 255)) x_offset = 0 for im in images: @@ -201,7 +215,8 @@ def create_side_by_side( max_width = max(widths) total_height = sum(heights) - new_im = Image.new('RGB', (max_width, total_height), color=(255, 255, 255)) + new_im = Image.new( + 'RGB', (max_width, total_height), color=(255, 255, 255)) y_offset = 0 for im in images: @@ -218,7 +233,8 @@ def create_side_by_side( def convert_colors(input_color, input_dice): """ Convert a list of color with the same size at the list input dice""" if isinstance(input_color, list): - colors = [REF_COLORS[c] for c in input_color if c in REF_COLORS.keys()] + colors = [REF_COLORS[c] + for c in input_color if c in REF_COLORS.keys()] if len(input_color) != len(colors): raise ColorError else: diff --git a/cogs/utils/_help.py b/bot/cogs/utils/_help.py similarity index 81% rename from cogs/utils/_help.py rename to bot/cogs/utils/_help.py index 061774a..4d8f7e7 100644 --- a/cogs/utils/_help.py +++ b/bot/cogs/utils/_help.py @@ -1,14 +1,9 @@ -import json +import os import discord - - -with open('config.json') as f: - config_arg = json.load(f)["command_prefix"] - +config_arg = os.environ.get('command_prefix') REF_COLOR = {'dice': '#8B0000'} - TITLE_DICE = "**Roll a random dice**" DESCRIPTION_COMMAND_DICE = f""" Roll one six sided die. @@ -17,9 +12,11 @@ ```{config_arg}d 2d4``` Roll one -101 to 150 sided die. ```{config_arg}d 1d[-101:150]``` - Add a one six sided die and a eight sided die (all display). + Add a one six sided die and a eight \ + sided die (all display). ```{config_arg}d 1d6 + 1d8 -v``` - Minus a one six sided die and a eight sided die (only output). + Minus a one six sided die and a eight sided \ + die (only output). ```{config_arg}d 1d6 - 1d8``` Add 6 at a one sided die. ```{config_arg}d 1d6 + 6``` @@ -37,17 +34,17 @@ Create a simple poll. ```{config_arg}poll Kenshuri is a troll?``` For advanced polls use the folowing syntax: - ```{config_arg}poll {{title}} [Option1] [Option2] [Option 3] ...``` + ```{config_arg}poll {{title}} [Option1] \ + [Option2] [Option 3] ...``` *Note: options are limited at 21.* """ - class HelperCommand: def __init__(self): pass def help(self, title, description_command): embed_m = discord.Embed(title=title, - description=description_command) + description=description_command) return embed_m diff --git a/cogs/utils/_poll.py b/bot/cogs/utils/_poll.py similarity index 100% rename from cogs/utils/_poll.py rename to bot/cogs/utils/_poll.py diff --git a/bot/cogs/utils/_ufd.py b/bot/cogs/utils/_ufd.py new file mode 100644 index 0000000..9fbb31c --- /dev/null +++ b/bot/cogs/utils/_ufd.py @@ -0,0 +1,212 @@ +import requests +from bs4 import BeautifulSoup + + +REF_ATK = {"ftilt": 'forward tilt', + 'utilt': 'up tilt', + 'dtilt': 'down tilt', + 'fsmash': 'forward smash', + 'dsmash': 'down smash', + 'upsmash': 'up smash', + 'nair': 'neutral air', + 'fair': 'forward air', + 'bair': 'back air', + 'uair': 'up air', + 'dair': 'down air', + 'nb': 'neutral b', + 'sb': 'side b', + 'ub': 'up b', + 'db': 'down b', + 'grab': 'grab', + 'jab': 'jab', + 'stats': 'stats', + 'da': 'dash attack'} + +DEFAULT_STATS = ["startup", "advantage", "activeframes", + "totalframes", "basedamage", "shieldstun"] + +DEFAULT_EXCLUDE_OVERALL_STATS = ['Stats', 'Initial Dash', + 'Walk Speed', + 'SH / FH / SHFF / FHFF Frames', + 'Shield Drop', 'Jump Squat'] + + +class UltimateFD: + def __init__(self, + character: str = None, + moves=None, + args_stats=None, + get_hitbox: bool = False, + exclude_stats: list = None, + exclude_moves: list = None): + + self.char = character + self.exclude_moves = exclude_stats if exclude_stats is not None else [ + 'movename', 'whichhitbox', 'notes'] + self.exclude_stats = exclude_moves if exclude_moves is not None else [ + 'dodge'] + self.url = "https://ultimateframedata.com/" + self.stats = {} + self.avalaible_stats = {} + self.args_stats = DEFAULT_STATS if args_stats is None else args_stats + + if character is None: + return + + moves = list(REF_ATK.keys()) if moves == 'all' else moves + moves = [moves] if isinstance(moves, str) else moves + + data_move = self.get_character_data(name=character) + + for move in moves: + st_move = self.get_character_moves(data=data_move, + move=move) + stats = self.get_stats_move(st_move, + get_hitbox, + *self.args_stats) + self.stats.update(stats) + + if not self.stats: + list_moves = list(REF_ATK.keys()) + raise ValueError(f"No moves found. Moves must be in: {list_moves}") + + def _get_soup(self, url): + """ + Request an url for a valid character + """ + response = requests.get(url) + if response.status_code != 200: + all_char = self.get_all_characters(self.url) + raise ValueError( + f'Choose a valid character in: {list(all_char.keys())}') + return BeautifulSoup(response.content, 'lxml') + + def get_stats_move(self, stats_move, image, *kwargs): + out_move = {} + + for move, stats in stats_move.items(): + out_stats = {} + available_stats = self._get_available_stats(st_move=stats) + self.avalaible_stats[move] = available_stats + + if self.args_stats == 'all': + kwargs = available_stats + + if 'stats' in move: + out_move[move] = self._format_overall_stats(stats) + continue + + if image: + if 'hitbox' in available_stats: + if stats.a is not None: + end_url_img = stats.a["data-featherlight"] + url_img = self.url + end_url_img + out_stats['hitbox'] = url_img + available_stats.remove('hitbox') + + for arg in kwargs: + if arg in available_stats: + val = self._format_stats(soup=stats, + class_name=arg) + out_stats[arg] = val + out_move[move] = out_stats + return out_move + + @staticmethod + def _format_stats(soup, class_name): + soup = soup.find(class_=class_name) + if soup is not None: + return soup.text.strip() + return None + + def get_character_data(self, name): + """ + Get list characters moves + """ + url_char = self.url + name + soup = self._get_soup(url_char) + + # for each movename not None get key (name) and value (stats) + data_move = {mv.find(class_='movename').text.strip().lower(): + mv for mv in soup.find_all(class_='movecontainer') + if mv.find(class_='movename') is not None} + return data_move + + def get_character_moves(self, data: dict, move: str): + """ + Select move in data moves + """ + selected_dict_move = {} + + # check if moves is in REF const and not in exclude moves + for move_k, move_s in data.items(): + for excluded_move in self.exclude_moves: + if self._check_move(move=move, move_k=move_k, + out=excluded_move): + selected_dict_move[move_k] = move_s + return selected_dict_move + + @staticmethod + def _check_move(move: str, move_k: str, out: str): + """ + Check for a move if: + - is in REF_ATK moves + - not in excluded moves + Return: + ------- + bool + """ + if move in REF_ATK: + if REF_ATK[move] in move_k: + if out not in move_k: + return True + return False + + def _get_available_stats(self, st_move): + """ + Parse BeautiFullSoup object to a list of available stats + """ + stats_list = set() + + for div in st_move.find_all('div'): + if div.has_attr('class'): + if len(div["class"]) != 0: + stats = " ".join(div['class']) + if stats not in self.exclude_stats: + stats_list.add(stats) + return stats_list + + def get_all_characters(self, url): + """ + Get list of all characters + + Return: + ------ + - dict, {character: url, ...} + """ + soup = self._get_soup(url) + characters = {} + + for balise in soup.find_all('a'): + href = balise["href"] + # href contain character name (remove # or http values) + if ('#' not in href) and ("http" not in href) and ('stats' not in href): + name = href.replace('/', '') + url_c = url + name + characters[name] = url_c + return characters + + def _format_overall_stats(self, soup): + overall_stats = {} + for st in soup.find_all('div'): + if not any(check in st.text + for check in DEFAULT_EXCLUDE_OVERALL_STATS): + if ' — ' in st.text: + spiting_stats = st.text.split(" — ") + if len(spiting_stats) == 2: + name, value = spiting_stats + overall_stats[name] = value + else: + name = spiting_stats[0] + value = " ".join(spiting_stats[1:]) + return overall_stats diff --git a/requirements.txt b/bot/requirements.txt similarity index 68% rename from requirements.txt rename to bot/requirements.txt index fddc7e2..bd79e05 100644 --- a/requirements.txt +++ b/bot/requirements.txt @@ -1,7 +1,9 @@ beautifulsoup4==4.10.0 discord.py==1.7.3 lxml==4.6.3 -matplotlib==3.4.3 +matplotlib==3.5.0 numpy==1.21.2 Pillow==8.3.2 requests==2.26.0 +pytest==6.2.5 +dpytest==0.5.3 \ No newline at end of file diff --git a/bot/setup.py b/bot/setup.py new file mode 100644 index 0000000..f7e580e --- /dev/null +++ b/bot/setup.py @@ -0,0 +1,9 @@ +from setuptools import setup, find_packages + +with open('requirements.txt', 'r', encoding='utf-8') as f: + REQUIRED_PACKAGES = f.readlines() + +setup(name="bot", + install_requires=REQUIRED_PACKAGES, + packages=find_packages(), + description='Bot SSBU') diff --git a/ci/build.sh b/ci/build.sh index ef845e9..4fa6c6a 100644 --- a/ci/build.sh +++ b/ci/build.sh @@ -4,4 +4,5 @@ set -e make build make up make test +make pytest make down \ No newline at end of file diff --git a/ci/deploy.sh b/ci/deploy.sh new file mode 100644 index 0000000..e69de29 diff --git a/cogs/owner.py b/cogs/owner.py deleted file mode 100644 index 89c29a3..0000000 --- a/cogs/owner.py +++ /dev/null @@ -1,37 +0,0 @@ -from discord.ext import commands - - -class Owner(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.path = 'cogs.' - - @commands.command(name="load", hidden=True) - @commands.is_owner() - async def load(self, ctx, *, cog: str): - self.bot.load_extension(self.path + cog) - await ctx.send("**`SUCCESS`**") - - @commands.command(name="unload", hidden=True) - @commands.is_owner() - async def unload(self, ctx, *, cog: str): - self.bot.unload_extension(self.path + cog) - await ctx.send("**`SUCCESS`**") - - @commands.command(name="reload", hidden=True) - @commands.is_owner() - async def reload(self, ctx, *, cog: str): - self.bot.unload_extension(self.path + cog) - self.bot.load_extension(self.path + cog) - await ctx.send("**`SUCCESS`**") - - @reload.error - @load.error - @unload.error - async def error_owner(self, ctx, error): - if isinstance(error, commands.NotOwner): - await ctx.send(f"ERROR: {error}") - - -def setup(bot): - bot.add_cog(Owner(bot)) diff --git a/cogs/utils/__init__.py b/cogs/utils/__init__.py deleted file mode 100644 index 407e28a..0000000 --- a/cogs/utils/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ._ufd import REF_ATK, DEFAULT_EXCLUDE_OVERALL_STATS, DEFAULT_STATS, UltimateFD -from ._poll import EMOJI -from ._help import HelperCommand, TITLE_UFD, DESCRIPTION_COMMAND_UFD, TITLE_POLL, DESCRIPTION_COMMAND_POLL, TITLE_DICE, DESCRIPTION_COMMAND_DICE -from ._args import ParseArgs diff --git a/cogs/utils/_ufd.py b/cogs/utils/_ufd.py deleted file mode 100644 index 09ffe59..0000000 --- a/cogs/utils/_ufd.py +++ /dev/null @@ -1,171 +0,0 @@ -import requests -from bs4 import BeautifulSoup - - -REF_ATK = {"ftilt": 'forward tilt', - 'utilt': 'up tilt', - 'dtilt': 'down tilt', - 'fsmash': 'forward smash', - 'dsmash': 'down smash', - 'upsmash': 'up smash', - 'nair': 'neutral air', - 'fair': 'forward air', - 'bair': 'back air', - 'uair': 'up air', - 'dair': 'down air', - 'nb': 'neutral b', - 'sb': 'side b', - 'ub': 'up b', - 'db': 'down b', - 'grab': 'grab', - 'jab': 'jab', - 'stats': 'stats', - 'da': 'dash attack'} - -DEFAULT_STATS = ["startup", "advantage", "activeframes", - "totalframes", "basedamage", "shieldstun"] - -DEFAULT_EXCLUDE_OVERALL_STATS = ["Stats", "Initial Dash", 'Walk Speed', - "SH / FH / SHFF / FHFF Frames", 'Shield Drop', 'Jump Squat'] - - -class UltimateFD: - def __init__(self, - character, - moves, - args_stats=None, - get_hitbox=False, - exclude_stats=['movename', - 'whichhitbox', 'notes'], - exclude_moves=['dodge']): - - self.char = character - self.out = exclude_moves - self.exclude_stats = exclude_stats - self.image = get_hitbox - self.url = "https://ultimateframedata.com/" - self.stats = {} - self.all_char = self.get_all_characters(self.url) - self.avalaible_stats = {} - self.args_stats = DEFAULT_STATS if args_stats is None else args_stats - if character is None: - return - - moves = list(REF_ATK.keys()) if moves == 'all' else moves - - for move in moves: - self._st_move = self.get_character_moves(name=character, - move=move) - - stats = self.get_stats_move(self._st_move, - self.image, - *self.args_stats) - self.stats.update(stats) - - if not self.stats: - list_moves = list(REF_ATK.keys()) - raise KeyError(f'No moves found. Moves must be in: {list_moves}') - - - def _get_soup(self, url): - r = requests.get(url) - if r.status_code != 200: - raise ValueError( - f'Choose a valid character in: {list(self.all_char.keys())}') - return BeautifulSoup(r.content, 'lxml') - - def get_stats_move(self, stats_move, image, *kwargs): - out_move = {} - for m, s in stats_move.items(): - out_stats = {} - available_stats = self._get_available_stats(st_move=s, - exclude_stats=self.exclude_stats) - self.avalaible_stats[m] = available_stats - - if self.args_stats == 'all': - kwargs = available_stats - - if 'stats' in m: - out_move[m] = self._format_overall_stats(s) - continue - - if image: - if 'hitbox' in available_stats: - if s.a is not None: - end_url_img = s.a["data-featherlight"] - url_img = self.url + end_url_img - out_stats['hitbox'] = url_img - available_stats.remove('hitbox') - - for arg in kwargs: - if arg in available_stats: - val = self._format_stats(soup=s, - class_name=arg) - out_stats[arg] = val - out_move[m] = out_stats - return out_move - - def _format_stats(self, soup, class_name): - soup = soup.find(class_=class_name) - if soup is not None: - return soup.text.strip() - return None - - def get_character_moves(self, name, move): - url_char = self.url + name - soup = self._get_soup(url_char) - - data_move = {mv.find(class_='movename').text.strip().lower(): - mv for mv in soup.find_all(class_='movecontainer') - if mv.find(class_='movename') is not None} - - final_dict_move = {} - for m_k, m_s in data_move.items(): - for out_word in self.out: - if self._check_move(ref_atk=REF_ATK, move=move, m_k=m_k, out=out_word): - final_dict_move[m_k] = m_s - return final_dict_move - - def _check_move(self, ref_atk, move, m_k, out): - if move in ref_atk: - if ref_atk[move] in m_k: - if out not in m_k: - return True - return False - - def _get_available_stats(self, st_move, exclude_stats): - stats_list = set() - - for e in st_move.find_all('div'): - if e.has_attr('class'): - if len(e["class"]) != 0: - stats = " ".join(e['class']) - if stats not in exclude_stats: - stats_list.add(stats) - return stats_list - - def get_all_characters(self, url): - soup = self._get_soup(url) - characters = {} - - for balise in soup.find_all('a'): - href = balise["href"] - if (href.startswith('/')) and ('stats' not in href): - name = href.replace('/', '') - url_c = url + href - characters[name] = url_c - return characters - - def _format_overall_stats(self, soup): - overall_stats = {} - for st in soup.find_all('div'): - if not any(check in st.text for check in DEFAULT_EXCLUDE_OVERALL_STATS): - if ' — ' in st.text: - spiting_stats = st.text.split(" — ") - if len(spiting_stats) == 2: - name, value = spiting_stats - overall_stats[name] = value - else: - name = spiting_stats[0] - value = " ".join(spiting_stats[1:]) - return overall_stats diff --git a/config.json b/config.json index 48778ee..006476e 100644 --- a/config.json +++ b/config.json @@ -2,11 +2,11 @@ "description": "", "command_prefix": "!", "extensions": [ - "cogs.dice", - "cogs.john", - "cogs.poll", - "cogs.ufd", - "cogs.owner", - "cogs.settings" + "user.dice", + "user.john", + "user.poll", + "user.ufd", + "dev.owner", + "dev.settings" ] } \ No newline at end of file diff --git a/main.py b/main.py index 2332577..be4e1ee 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ from bot import Ramoloss -with open('config.json') as config_file: +with open('config.json', 'r', encoding='utf-8') as config_file: config = json.load(config_file) token = os.environ["DISCORD_TOKEN"] diff --git a/save/create-save.py b/save/create-save.py new file mode 100644 index 0000000..e27d506 --- /dev/null +++ b/save/create-save.py @@ -0,0 +1,18 @@ +import requests +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument('--url', '-u', nargs='*') +parser.add_argument('--name', '-n', nargs='*') + + +def write_response(url, file): + content = requests.get(url).content + with open(f'save/{file}.html', 'wb') as file: + file.write(content) + + +if __name__ == '__main__': + args = parser.parse_args() + for filename, url_name in zip(args.name, args.url): + write_response(file=filename, url=url_name) diff --git a/tests/bot/test_settings.py b/tests/bot/test_settings.py new file mode 100644 index 0000000..4ad9dc4 --- /dev/null +++ b/tests/bot/test_settings.py @@ -0,0 +1,27 @@ +import json +import pytest +import discord +import discord.ext.test as dpytest +from bot import Ramoloss + + +with open('config.json', 'r', encoding='utf-8') as config_file: + CONFIG = json.load(config_file) + + +@pytest.fixture +def bot_instance(event_loop): + intents = discord.Intents.default() + setattr(intents, 'members', True) + bot_ramoloss = Ramoloss(config=CONFIG, + token=None, + loop=event_loop, + intents=intents) + dpytest.configure(bot_ramoloss) + return bot_ramoloss + + +@pytest.mark.asyncio +async def test_ping(bot_instance): + await dpytest.message("!hello") + assert dpytest.verify().message().contains().content("Hello") diff --git a/tests/bot/test_ufd.py b/tests/bot/test_ufd.py new file mode 100644 index 0000000..bcf167a --- /dev/null +++ b/tests/bot/test_ufd.py @@ -0,0 +1,105 @@ +import json +import os +import requests +import pytest +import discord +import discord.ext.test as dpytest +from bot import Ramoloss +from bot.cogs.utils import UltimateFD, REF_ATK + + +with open('config.json', 'r', encoding='utf-8') as config_file: + config = json.load(config_file) + + +@pytest.fixture +def bot_instance(event_loop): + intents = discord.Intents.default() + setattr(intents, 'members', True) + bot_ramoloss = Ramoloss(config=config, + token=None, + loop=event_loop, + intents=intents) + dpytest.configure(bot_ramoloss) + return bot_ramoloss + + +def mock_get(*args, **kwargs): + class MockResponse: + def __init__(self, content): + self.status_code = 200 + self.content = content + + def get(self): + return self.content + print(args[0]) + if 'wario' in args[0]: + file_content = open_file('wario') + else: + file_content = open_file('index') + return MockResponse(file_content) + + +def open_file(filename): + with open(os.path.join('save', f'{filename}.html'), 'r', encoding='utf-8') as file: + file_content = str(file.readlines()) + return file_content + + +class TestUFD: + def test_char(self, monkeypatch): + """ + Test list of character is valid + """ + monkeypatch.setattr(requests, 'get', mock_get) + ufd = UltimateFD() + char = list(ufd.get_all_characters(ufd.url).keys()) + assert 'wario' in char + assert 'sora' in char + assert 'donkey_kong' in char + assert 'http' not in char + + def test_move(self, monkeypatch): + monkeypatch.setattr(requests, 'get', mock_get) + ufd = UltimateFD(character='wario', + moves='fair') + move = ufd.stats + assert any(REF_ATK["fair"] in key for key in move) + + def test_list_moves(self): + ufd = UltimateFD(character='wario', + moves=['ub', 'nair']) + move = ufd.stats + assert any(REF_ATK["ub"] in key for key in move) + assert any(REF_ATK["nair"] in key for key in move) + + +class TestDiscordUFD: + @pytest.mark.asyncio + async def test_move_command(self, bot_instance): + await dpytest.message("!ufd list moves") + description = dpytest.get_embed().description + assert 'dair' in description + assert 'fsmash' in description + assert 'nb' in description + + @pytest.mark.asyncio + async def test_char_command(self, bot_instance): + await dpytest.message("!ufd list char") + description = dpytest.get_embed().description + assert 'wario' in description + assert 'sora' in description + assert 'captain_falcon' in description + + @pytest.mark.asyncio + async def test_wario_command_title(self, bot_instance): + await dpytest.message("!ufd wario ub") + title = dpytest.get_embed().title + assert 'Wario' in title + assert 'Up B' in title + + async def test_wario_command_stats(self, bot_instance): + await dpytest.message("!ufd wario ub") + fields = dpytest.get_embed().fields + assert 'Startup' in fields + assert 'Shieldstun' in fields diff --git a/tests/test-bot.sh b/tests/test-bot.sh new file mode 100644 index 0000000..6014c3b --- /dev/null +++ b/tests/test-bot.sh @@ -0,0 +1 @@ +docker exec $NAME-bot pytest \ No newline at end of file