diff --git a/backend/server.py b/backend/server.py index 3f76518..3450233 100644 --- a/backend/server.py +++ b/backend/server.py @@ -19,7 +19,7 @@ ipc_client = ipc.Client(secret_key="SOME_SECRET_KEY") @app.route("/ping", methods=["GET"]) async def ping(): """ - Send a ping request, monitors bot latency, endpoint time, and PSQL latency + Send a ping request, monitors bot latency and endpoint time """ latency = await ipc_client.request("get_bot_latency") diff --git a/cogs/birthdays.py b/cogs/birthdays.py index 2c3c284..8c0f351 100644 --- a/cogs/birthdays.py +++ b/cogs/birthdays.py @@ -163,8 +163,8 @@ class Birthdays(commands.Cog): # Create a datetime object for this birthday timeString = "{}/{}/{}".format( - stringFormatters.leadingZero(str(day)), - stringFormatters.leadingZero(str(month)), + stringFormatters.leading_zero(str(day)), + stringFormatters.leading_zero(str(month)), year ) diff --git a/cogs/define.py b/cogs/define.py index 23eba64..26adb40 100644 --- a/cogs/define.py +++ b/cogs/define.py @@ -1,11 +1,8 @@ -import os - +from data.embeds.urban_dictionary import Definition from decorators import help -import discord from discord.ext import commands from enums.help_categories import Category from functions import checks -import requests class Define(commands.Cog): @@ -19,99 +16,15 @@ class Define(commands.Cog): @commands.command(name="Define", aliases=["UrbanDictionary", "Ud"], usage="[Woord]") @commands.check(checks.allowedChannels) @help.Category(category=Category.Other) - async def define(self, ctx, *words): + async def define(self, ctx, *, query): """ Command that looks up the definition of a word in the Urban Dictionary. :param ctx: Discord Context - :param words: Word(s) to look up + :param query: Word(s) to look up """ - words = list(words) - if len(words) == 0: - return await ctx.send("Controleer je argumenten.") - - query = " ".join(words) - answer = self.lookup(query) - - embed = discord.Embed(colour=discord.Colour.from_rgb(220, 255, 0)) - embed.set_author(name="Urban Dictionary") - - embed.add_field(name="Woord", value=answer["word"], inline=True) - embed.add_field(name="Auteur", value=answer["author"], inline=True) - embed.add_field(name="Definitie", value=self.cleanString(answer["definition"]), inline=False) - embed.add_field(name="Voorbeeld", value=self.cleanString(answer["example"]), inline=False) - embed.add_field(name="Rating", value=str(round(self.ratio(answer), 2)) + "%") - embed.add_field(name="Link naar de volledige definitie", - value="[Urban Dictionary]({})".format(str(answer["link"]))) - + embed = Definition(query).to_embed() await ctx.send(embed=embed) - def lookup(self, word): - """ - Function that sends the API request to get the definition. - :param word: the woord to look up - :return: a dictionary representing the info of this word - """ - url = "https://mashape-community-urban-dictionary.p.rapidapi.com/define" - - querystring = {"term": word} - - headers = { - 'x-rapidapi-host': "mashape-community-urban-dictionary.p.rapidapi.com", - 'x-rapidapi-key': os.getenv("URBANDICTIONARY") - } - - try: - if word.lower() == "didier": - raise Exception - - response = requests.request("GET", url, headers=headers, params=querystring).json()["list"] - - if len(response) > 0: - return {"word": response[0]["word"], "definition": response[0]["definition"], - "example": response[0]["example"], "thumbs_up": response[0]["thumbs_up"], - "thumbs_down": response[0]["thumbs_down"], "link": response[0]["permalink"], - "author": response[0]["author"]} - - # No valid response - return self.defineDidier() - except Exception: - return self.defineDidier() - - def cleanString(self, text: str): - """ - Function that cuts off definitions that are too long & strips out UD markdown - from an input string. - :param text: the input string to clean up - :return: the edited version of the string - """ - text = text.replace("[", "") - text = text.replace("]", "") - - if not text: - return "N/A" - - return text if len(text) < 1024 else text[:1021] + "..." - - def ratio(self, dic): - """ - Function that alculates the upvote/downvote ratio of the definition. - :param dic: the dictionary representing the definition - :return: the upvote/downvote ratio (float) - """ - return (100 * int(dic["thumbs_up"])) / (int(dic["thumbs_up"]) + int(dic["thumbs_down"])) \ - if int(dic["thumbs_down"]) != 0 else 100.0 - - def defineDidier(self): - """ - Function that returns a stock dictionary to define Didier - in case people call it, or no definition was found. - :return: a dictionary that defines Didier - """ - return {"word": "Didier", "definition": "Didier", "example": "1: Didier\n2: Hmm?", "thumbs_up": 69420, - "thumbs_down": 0, "author": "Didier", - "link": "https://upload.wikimedia.org/wikipedia/commons/a/a5" - "/Didier_Reynders_in_Iranian_Parliament_02.jpg"} - def setup(client): client.add_cog(Define(client)) diff --git a/cogs/events.py b/cogs/events.py index 9069c3b..28be9c5 100644 --- a/cogs/events.py +++ b/cogs/events.py @@ -1,15 +1,16 @@ +from dislash import SlashInteraction + from data import constants from data.snipe import Snipe, Action, should_snipe import datetime import discord from discord.ext import commands -from functions import checks, easterEggResponses +from functions import checks, easterEggResponses, stringFormatters from functions.database import stats, muttn, custom_commands, commands as command_stats import pytz -from settings import READY_MESSAGE, SANDBOX, STATUS_MESSAGE +from settings import READY_MESSAGE, SANDBOX from startup.didier import Didier import time -import traceback class Events(commands.Cog): @@ -33,9 +34,6 @@ class Events(commands.Cog): """ Function called when the bot is ready & done leading. """ - # Set status - await self.client.change_presence(status=discord.Status.online, activity=discord.Game(STATUS_MESSAGE)) - print(READY_MESSAGE) # Add constants to the client as a botvar @@ -87,12 +85,9 @@ class Events(commands.Cog): Logs commands in your terminal. :param ctx: Discord Context """ - DM = ctx.guild is None - print("{} in {}: {}".format(ctx.author.display_name, - "DM" if DM else "{} ({})".format(ctx.channel.name, ctx.guild.name), - ctx.message.content)) + print(stringFormatters.format_command_usage(ctx)) - command_stats.invoked() + command_stats.invoked(command_stats.InvocationType.TextCommand) @commands.Cog.listener() async def on_command_error(self, ctx, err): @@ -101,8 +96,8 @@ class Events(commands.Cog): :param ctx: Discord Context :param err: the error thrown """ - # Zandbak Didier shouldn't spam the error logs - if self.client.user.id == int(constants.coolerDidierId): + # Debugging Didier shouldn't spam the error logs + if self.client.user.id != int(constants.didierId): raise err # Don't handle commands that have their own custom error handler @@ -123,14 +118,26 @@ class Events(commands.Cog): elif isinstance(err, (commands.BadArgument, commands.MissingRequiredArgument, commands.UnexpectedQuoteError)): await ctx.send("Controleer je argumenten.") else: - # Remove the InvokeCommandError because it's useless information - x = traceback.format_exception(type(err), err, err.__traceback__) - errorString = "" - for line in x: - if "direct cause of the following" in line: - break - errorString += line.replace("*", "") + "\n" if line.strip() != "" else "" - await self.sendErrorEmbed(ctx, err, errorString) + usage = stringFormatters.format_command_usage(ctx) + await self.sendErrorEmbed(err, "Command", usage) + + @commands.Cog.listener() + async def on_slash_command(self, interaction: SlashInteraction): + """ + Function called whenever someone uses a slash command + """ + print(stringFormatters.format_slash_command_usage(interaction)) + + command_stats.invoked(command_stats.InvocationType.SlashCommand) + + @commands.Cog.listener() + async def on_slash_command_error(self, interaction, err): + # Debugging Didier shouldn't spam the error logs + if self.client.user.id != int(constants.didierId): + raise err + + usage = stringFormatters.format_slash_command_usage(interaction) + await self.sendErrorEmbed(err, "Slash Command", usage) @commands.Cog.listener() async def on_raw_reaction_add(self, react): @@ -286,19 +293,15 @@ class Events(commands.Cog): self.client.snipe[message.channel.id] = Snipe(message.author.id, message.channel.id, message.guild.id, Action.Remove, message.content) - async def sendErrorEmbed(self, ctx, error: Exception, trace): + async def sendErrorEmbed(self, error: Exception, error_type: str, usage: str): """ Function that sends an error embed in #ErrorLogs. - :param ctx: Discord Context - :param error: the error thrown - :param trace: the stacktrace of the error """ + trace = stringFormatters.format_error_tb(error) + embed = discord.Embed(colour=discord.Colour.red()) embed.set_author(name="Error") - embed.add_field(name="Command:", value="{} in {}: {}".format(ctx.author.display_name, - ctx.channel.name if str( - ctx.channel.type) != "private" else "DM", - ctx.message.content), inline=False) + embed.add_field(name=f"{error_type}:", value=usage, inline=False) embed.add_field(name="Error:", value=str(error)[:1024], inline=False) embed.add_field(name="Message:", value=str(trace)[:1024], inline=False) diff --git a/cogs/faq.py b/cogs/faq.py index 8817b29..1b198fe 100644 --- a/cogs/faq.py +++ b/cogs/faq.py @@ -37,7 +37,7 @@ class Faq(commands.Cog): return await self.faqCategory(ctx, (constants.faq_channels[ctx.channel.id],)) # List of all categories with the first letter capitalized - resp = [stringFormatters.titleCase(cat[0]) for cat in faq.getCategories()] + resp = [stringFormatters.title_case(cat[0]) for cat in faq.getCategories()] # Sort alphabetically resp.sort() @@ -146,7 +146,7 @@ class Faq(commands.Cog): resp.sort(key=lambda x: int(x[0])) embed = discord.Embed(colour=discord.Colour.blue()) - embed.set_author(name="FAQ {}".format(stringFormatters.titleCase(category))) + embed.set_author(name="FAQ {}".format(stringFormatters.title_case(category))) # Add everything into the embed for i, pair in enumerate(resp): diff --git a/cogs/football.py b/cogs/football.py index 1f77c85..d02ec15 100644 --- a/cogs/football.py +++ b/cogs/football.py @@ -2,7 +2,7 @@ from decorators import help from discord.ext import commands from enums.help_categories import Category from functions import checks, config -from functions.football import getMatches, getTable, get_jpl_code +from functions.football import get_matches, get_table, get_jpl_code class Football(commands.Cog): @@ -20,21 +20,16 @@ class Football(commands.Cog): pass @jpl.command(name="Matches", aliases=["M"], usage="[Week]*") - async def matches(self, ctx, *args): - args = list(args) - + async def matches(self, ctx, day: int = None): # Default is current day - if not args: - args = [str(config.get("jpl_day"))] + if day is None: + day = int(config.get("jpl_day")) - if all(letter.isdigit() for letter in args[0]): - await ctx.send(getMatches(int(args[0]))) - else: - return await ctx.send("Dit is geen geldige speeldag.") + await ctx.send(get_matches(day)) @jpl.command(name="Table", aliases=["Ranking", "Rankings", "Ranks", "T"]) - async def table(self, ctx, *args): - await ctx.send(getTable()) + async def table(self, ctx): + await ctx.send(get_table()) @commands.check(checks.isMe) @jpl.command(name="Update") diff --git a/cogs/fun.py b/cogs/fun.py index 8d7a381..11b0a21 100644 --- a/cogs/fun.py +++ b/cogs/fun.py @@ -120,7 +120,7 @@ class Fun(commands.Cog): memeList = memes.getAllMemes() # Turn the list into a list of [Name: fields] - memeList = [": ".join([stringFormatters.titleCase(meme[1]), + memeList = [": ".join([stringFormatters.title_case(meme[1]), str(meme[2])]) for meme in sorted(memeList, key=lambda x: x[1])] pages = paginatedLeaderboard.Pages(source=paginatedLeaderboard.Source(memeList, "Memes", discord.Colour.blue()), diff --git a/cogs/google.py b/cogs/google.py index 446d6d8..204075e 100644 --- a/cogs/google.py +++ b/cogs/google.py @@ -1,8 +1,7 @@ -import discord from discord.ext import commands from decorators import help from enums.help_categories import Category -from functions.scrapers.google import google_search +from functions.scrapers.google import google_search, create_google_embed class Google(commands.Cog): @@ -19,33 +18,12 @@ class Google(commands.Cog): if not query: return await ctx.reply("Je hebt geen query opgegeven.", mention_author=True) - results, status = google_search(" ".join(query)) + result = google_search(" ".join(query)) - if results is None: - return await ctx.send("Er ging iets fout (Response {})".format(status)) - - # Filter out all Nones - elements = list(filter(lambda x: x is not None, results)) - - embed = discord.Embed(colour=discord.Colour.blue()) - embed.set_author(name="Google Search") - - # Empty list of results - if len(elements) == 0: - embed.description = "Geen resultaten gevonden." - return await ctx.reply(embed=embed, mention_author=False) - - # Cut excess results out - if len(elements) > 10: - elements = elements[:10] - - links = [] - - for index, (link, title) in enumerate(elements): - links.append("{}: [{}]({})".format(index + 1, title, link)) - - embed.description = "\n".join(links) + if not result.results: + return await ctx.send("Er ging iets fout (Response {})".format(result.status_code)) + embed = create_google_embed(result) await ctx.reply(embed=embed, mention_author=False) diff --git a/cogs/modCommands.py b/cogs/modCommands.py index ab24afb..a6f2fda 100644 --- a/cogs/modCommands.py +++ b/cogs/modCommands.py @@ -182,15 +182,13 @@ class ModCommands(commands.Cog): embed = discord.Embed(colour=discord.Colour.blue()) embed.set_author(name=user.display_name, icon_url=user.avatar_url) - embed.add_field(name="Discriminator", value="#{}".format(user.discriminator)) + embed.add_field(name="Discriminator", value=f"#{user.discriminator}") embed.add_field(name="Discord id", value=user.id) embed.add_field(name="Bot", value="Nee" if not user.bot else "Ja") created_local = timeFormatters.epochToDate(user.created_at.timestamp()) - embed.add_field(name="Account aangemaakt", value="{}\n({} geleden)".format( - created_local["date"], timeFormatters.diffYearBasisString(round(created_local["dateDT"].timestamp())) - ), inline=False) + embed.add_field(name="Account aangemaakt", value=f"", inline=False) # Check if the user is in the current guild if ctx.guild is not None: @@ -199,9 +197,8 @@ class ModCommands(commands.Cog): if member_instance is not None: joined_local = timeFormatters.epochToDate(member_instance.joined_at.timestamp()) - embed.add_field(name="Lid geworden van {} op".format(ctx.guild.name), value="{}\n({} Geleden)".format( - joined_local["date"], timeFormatters.diffYearBasisString(round(joined_local["dateDT"].timestamp())) - )) + embed.add_field(name=f"Lid geworden van {ctx.guild.name}", + value=f"") embed.add_field(name="Mention String", value=member_instance.mention, inline=False) diff --git a/cogs/school.py b/cogs/school.py index 828f0a8..a206e64 100644 --- a/cogs/school.py +++ b/cogs/school.py @@ -42,7 +42,7 @@ class School(commands.Cog): embed.set_footer(text="Omwille van de coronamaatregelen is er een beperkter aanbod, en kan je enkel nog eten afhalen. Ter plaatse eten is niet meer mogelijk.") await ctx.send(embed=embed) - # @commands.command(name="Les", aliases=["Class", "Classes", "Sched", "Schedule"], usage="[Jaargang]* [Dag]*") + # @commands.command(name="Les", aliases=["Class", "Classes", "Sched", "Schedule"], usage="[Dag]*") # @commands.check(checks.allowedChannels) # @help.Category(category=Category.School) async def les(self, ctx, day=None): @@ -62,7 +62,7 @@ class School(commands.Cog): return await ctx.send(embed=s.create_schedule().to_embed()) @commands.command(name="Pin", usage="[Message]") - @help.Category(category=Category.School) + @help.Category(category=Category.Other) async def pin(self, ctx, message: discord.Message): # In case people abuse, check if they're blacklisted blacklist = [] diff --git a/cogs/slash/define_slash.py b/cogs/slash/define_slash.py new file mode 100644 index 0000000..b291bb9 --- /dev/null +++ b/cogs/slash/define_slash.py @@ -0,0 +1,24 @@ +from discord.ext import commands +from dislash import SlashInteraction, slash_command, Option, OptionType + +from data.embeds.urban_dictionary import Definition +from startup.didier import Didier + + +class DefineSlash(commands.Cog): + def __init__(self, client: Didier): + self.client: Didier = client + + @slash_command(name="define", + description="Urban Dictionary", + options=[ + Option("query", "Search query", OptionType.STRING, required=True) + ] + ) + async def _define_slash(self, interaction: SlashInteraction, query): + embed = Definition(query).to_embed() + await interaction.reply(embed=embed) + + +def setup(client: Didier): + client.add_cog(DefineSlash(client)) diff --git a/cogs/slash/football_slash.py b/cogs/slash/football_slash.py new file mode 100644 index 0000000..d5a7283 --- /dev/null +++ b/cogs/slash/football_slash.py @@ -0,0 +1,44 @@ +from discord.ext import commands +from dislash import SlashInteraction, slash_command, Option, OptionType +from functions import config, checks +from functions.football import get_matches, get_table, get_jpl_code +from startup.didier import Didier + + +class FootballSlash(commands.Cog): + def __init__(self, client: Didier): + self.client: Didier = client + + @slash_command(name="jpl", description="Jupiler Pro League commands") + async def _jpl_group(self, interaction: SlashInteraction): + pass + + @_jpl_group.sub_command(name="matches", + description="Schema voor een bepaalde speeldag", + options=[ + Option("day", "Speeldag (default huidige)", OptionType.INTEGER) + ] + ) + async def _jpl_matches_slash(self, interaction: SlashInteraction, day: int = None): + # Default is current day + if day is None: + day = int(config.get("jpl_day")) + + await interaction.reply(get_matches(day)) + + @_jpl_group.sub_command(name="table", description="Huidige rangschikking") + async def _jpl_table_slash(self, interaction: SlashInteraction): + await interaction.reply(get_table()) + + @_jpl_group.sub_command(name="update", description="Update de code voor deze competitie (owner-only)") + async def _jpl_update_slash(self, interaction: SlashInteraction): + if not await checks.isMe(interaction): + return await interaction.reply(f"Je hebt geen toegang tot dit commando.") + + code = get_jpl_code() + config.config("jpl", code) + await interaction.reply(f"Done (code: {code})") + + +def setup(client: Didier): + client.add_cog(FootballSlash(client)) diff --git a/cogs/slash/google_slash.py b/cogs/slash/google_slash.py new file mode 100644 index 0000000..6978492 --- /dev/null +++ b/cogs/slash/google_slash.py @@ -0,0 +1,28 @@ +from discord.ext import commands +from dislash import slash_command, SlashInteraction, Option, OptionType +from functions.scrapers.google import google_search, create_google_embed +from startup.didier import Didier + + +class GoogleSlash(commands.Cog): + def __init__(self, client: Didier): + self.client: Didier = client + + @slash_command(name="google", + description="Google search", + options=[ + Option("query", "Search query", OptionType.STRING, required=True) + ] + ) + async def _google_slash(self, interaction: SlashInteraction, query: str): + result = google_search(query) + + if not result.results: + return await interaction.reply("Er ging iets fout (Response {})".format(result.status_code)) + + embed = create_google_embed(result) + await interaction.reply(embed=embed) + + +def setup(client: Didier): + client.add_cog(GoogleSlash(client)) diff --git a/cogs/slash/translate_slash.py b/cogs/slash/translate_slash.py new file mode 100644 index 0000000..6ee293d --- /dev/null +++ b/cogs/slash/translate_slash.py @@ -0,0 +1,27 @@ +from discord.ext import commands +from dislash import SlashInteraction, slash_command, Option, OptionType + +from data.embeds.translate import Translation +from startup.didier import Didier + + +class TranslateSlash(commands.Cog): + def __init__(self, client: Didier): + self.client: Didier = client + + @slash_command( + name="translate", + description="Google Translate", + options=[ + Option("text", "Tekst om te vertalen", OptionType.STRING, required=True), + Option("from_lang", "Taal om van te vertalen (default auto-detect)", OptionType.STRING), + Option("to_lang", "Taal om naar te vertalen (default NL)", OptionType.STRING) + ] + ) + async def _translate_slash(self, interaction: SlashInteraction, text: str, from_lang: str = "auto", to_lang: str = "nl"): + translation = Translation(text=text, fr=from_lang.lower(), to=to_lang.lower()) + await interaction.reply(embed=translation.to_embed()) + + +def setup(client: Didier): + client.add_cog(TranslateSlash(client)) diff --git a/cogs/tasks.py b/cogs/tasks.py index 7f17b2d..8209d19 100644 --- a/cogs/tasks.py +++ b/cogs/tasks.py @@ -35,7 +35,7 @@ class Tasks(commands.Cog): # Don't do it multiple times a day if bot dc's, ... with open("files/lastTasks.json", "r") as fp: lastTasks = json.load(fp) - if int(self.getCurrentHour()) == 0 and int(time.time()) - int(lastTasks["interest"]) > 10000: + if int(self.getCurrentHour()) == 4 and int(time.time()) - int(lastTasks["interest"]) > 10000: users = currency.getAllRows() bitcoinPrice = self.getCurrentBitcoinPrice() for user in users: @@ -188,7 +188,7 @@ class Tasks(commands.Cog): # Don't do it multiple times a day if bot dc's, ... with open("files/lastTasks.json", "r") as fp: lastTasks = json.load(fp) - if int(self.getCurrentHour()) == 7 and int(time.time()) - int(lastTasks["remind"]) > 10000: + if int(self.getCurrentHour()) == 4 and int(time.time()) - int(lastTasks["remind"]) > 10000: reminders = Reminders() weekday = self.getCurrentWeekday() diff --git a/cogs/translate.py b/cogs/translate.py index e607ca8..74186b7 100644 --- a/cogs/translate.py +++ b/cogs/translate.py @@ -2,7 +2,7 @@ from decorators import help import discord from discord.ext import commands from enums.help_categories import Category -from functions.stringFormatters import titleCase as tc +from functions.stringFormatters import title_case as tc from googletrans import Translator, LANGUAGES import re diff --git a/data/embeds/translate.py b/data/embeds/translate.py new file mode 100644 index 0000000..32f4853 --- /dev/null +++ b/data/embeds/translate.py @@ -0,0 +1,61 @@ +import discord +from googletrans import Translator, LANGUAGES +from functions.stringFormatters import title_case +from typing import Optional + + +class Translation: + def __init__(self, text: str, fr: str, to: str): + self.text = text + self.fr = fr + self.to = to + self.embed: Optional[discord.Embed] = None + self.translation = None + + self.translate(text, fr, to) + + def translate(self, query: str, fr: str, to: str): + """ + Translate [query] into [to] + """ + try: + translator = Translator() + self.translation = translator.translate(query, to, fr) + except ValueError as e: + message = str(e) + + if "destination" in message: + self._create_error_embed(f"{title_case(to)} is geen geldige taal.") + return + + if "source" in message: + self._create_error_embed(f"{title_case(fr)} is geen geldige taal.") + return + + raise e + + def _create_error_embed(self, message): + embed = discord.Embed(colour=discord.Colour.red()) + embed.set_author(name="Didier Translate") + embed.description = message + self.embed = embed + + def to_embed(self) -> discord.Embed: + # There's an error embed to show + if self.embed is not None: + return self.embed + + embed = discord.Embed(colour=discord.Colour.blue()) + embed.set_author(name="Didier Translate") + + if self.fr == "auto": + language = self.translation.src + embed.add_field(name="Gedetecteerde taal", value=title_case(LANGUAGES[language])) + + if self.translation.extra_data["confidence"] is not None: + embed.add_field(name="Zekerheid", value="{}%".format(self.translation.extra_data["confidence"] * 100)) + + embed.add_field(name="Origineel ({})".format(self.translation.src.upper()), value=self.text, inline=False) + embed.add_field(name="Vertaling ({})".format(self.to.upper()), value=self.translation.text) + + return embed diff --git a/data/embeds/ufora.py b/data/embeds/ufora.py index c445e1b..613c932 100644 --- a/data/embeds/ufora.py +++ b/data/embeds/ufora.py @@ -1,6 +1,6 @@ from datetime import datetime from discord import Embed, Colour -from functions.stringFormatters import leadingZero as lz +from functions.stringFormatters import leading_zero as lz from functions.timeFormatters import intToWeekday from markdownify import markdownify as md import pytz diff --git a/data/embeds/urban_dictionary.py b/data/embeds/urban_dictionary.py new file mode 100644 index 0000000..763d08f --- /dev/null +++ b/data/embeds/urban_dictionary.py @@ -0,0 +1,112 @@ +import discord +import os +import requests +from typing import Dict + + +class Definition: + def __init__(self, query: str): + self.query = query + self.definition = Definition.lookup(query) + + @staticmethod + def lookup(word) -> Dict: + """ + Function that sends the API request to get the definition. + :param word: the woord to look up + :return: a dictionary representing the info of this word + """ + url = "https://mashape-community-urban-dictionary.p.rapidapi.com/define" + + querystring = {"term": word} + + headers = { + 'x-rapidapi-host': "mashape-community-urban-dictionary.p.rapidapi.com", + 'x-rapidapi-key': os.getenv("URBANDICTIONARY") + } + + try: + if word.lower() == "didier": + return Definition.define_didier() + + response = requests.get(url, headers=headers, params=querystring).json()["list"] + + if len(response) > 0: + return {"word": response[0]["word"], "definition": response[0]["definition"], + "example": response[0]["example"], "thumbs_up": response[0]["thumbs_up"], + "thumbs_down": response[0]["thumbs_down"], "link": response[0]["permalink"], + "author": response[0]["author"]} + + # No valid response + return {} + except Exception: + return Definition.define_didier() + + @staticmethod + def clean_string(text: str): + """ + Function that cuts off definitions that are too long & strips out UD markdown + from an input string. + :param text: the input string to clean up + :return: the edited version of the string + """ + text = text.replace("[", "") + text = text.replace("]", "") + + if not text: + return "N/A" + + return text if len(text) < 1024 else text[:1021] + "..." + + @staticmethod + def ratio(dic) -> float: + """ + Function that calculates the upvote/downvote ratio of the definition. + :param dic: the dictionary representing the definition + :return: the upvote/downvote ratio (float) + """ + return (100 * int(dic["thumbs_up"])) / (int(dic["thumbs_up"]) + int(dic["thumbs_down"])) \ + if int(dic["thumbs_down"]) != 0 else 100.0 + + @staticmethod + def define_didier() -> Dict: + """ + Function that returns a stock dictionary to define Didier + in case people call it, or no definition was found. + :return: a dictionary that defines Didier + """ + return {"word": "Didier", "definition": "Didier", "example": "1: Didier\n2: Hmm?", "thumbs_up": 69420, + "thumbs_down": 0, "author": "Didier", + "link": "https://upload.wikimedia.org/wikipedia/commons/a/a5" + "/Didier_Reynders_in_Iranian_Parliament_02.jpg"} + + def to_embed(self) -> discord.Embed: + """ + Create an embed for this definition + """ + # No results found + if not self.definition: + return self._nothing_found_embed() + + embed = discord.Embed(colour=discord.Colour.from_rgb(220, 255, 0)) + embed.set_author(name="Urban Dictionary") + + embed.add_field(name="Woord", value=self.definition["word"], inline=True) + embed.add_field(name="Auteur", value=self.definition["author"], inline=True) + embed.add_field(name="Definitie", value=Definition.clean_string(self.definition["definition"]), inline=False) + embed.add_field(name="Voorbeeld", value=Definition.clean_string(self.definition["example"]), inline=False) + embed.add_field(name="Rating", value=str(round(Definition.ratio(self.definition), 2)) + "%") + embed.add_field(name="Link naar de volledige definitie", + value="[Urban Dictionary]({})".format(str(self.definition["link"]))) + + return embed + + def _nothing_found_embed(self) -> discord.Embed: + """ + Special embed when no results could be found + """ + embed = discord.Embed(colour=discord.Colour.red(), title=self.query[:256]) + embed.set_author(name="Urban Dictionary") + embed.description = "Geen resultaten gevonden" + + return embed diff --git a/didier.py b/didier.py index 551161e..4530c68 100644 --- a/didier.py +++ b/didier.py @@ -1,18 +1,23 @@ import discord from dotenv import load_dotenv from functions.prefixes import get_prefix -from settings import TOKEN +from settings import STATUS_MESSAGE, TOKEN from startup.didier import Didier if __name__ == "__main__": load_dotenv(verbose=True) + # Activities + activity = discord.Activity(type=discord.ActivityType.playing, name=STATUS_MESSAGE) + status = discord.Status.online + # Configure intents (1.5.0) intents = discord.Intents.default() intents.members = True - client = Didier(command_prefix=get_prefix, case_insensitive=True, intents=intents) + client = Didier(command_prefix=get_prefix, case_insensitive=True, intents=intents, activity=activity, status=status) + # Run IPC server if necessary if client.ipc is not None: client.ipc.start() diff --git a/files/help.json b/files/help.json index d6112ac..5738649 100644 --- a/files/help.json +++ b/files/help.json @@ -58,7 +58,7 @@ "jpl table": "De huidige stand van het klassement.", "jpl update": "Haalt de nieuwe code voor de competitie van dit jaar op.", "leaderboard": "Bekijk de Top 10 van [Categorie].\nIndien je geen categorie opgeeft krijg je een lijst van categorieën.", - "les": "Bekijk het lessenrooster voor [Dag] in het [Jaargang]-de jaar.\nIndien je geen dag opgeeft, is dit standaard vandaag. De jaargang is standaard 2.\nLes Morgen/Overmorgen werkt ook.", + "les": "Bekijk het lessenrooster voor [Dag].\nIndien je geen dag opgeeft, is dit standaard vandaag.\nLes Morgen/Overmorgen werkt ook.", "lmgtfy": "Stuur iemand een LMGTFY link wanneer ze je een domme vraag stellen in plaats van het zelf op te zoeken.\nQueries met spaties moeten **niet** tussen aanhalingstekens staan.", "load": "Laadt [Cog] in.", "load all": "Laadt alle cogs in.", diff --git a/functions/checks.py b/functions/checks.py index 0cbb042..6d18dfe 100644 --- a/functions/checks.py +++ b/functions/checks.py @@ -1,8 +1,4 @@ -import math - import discord -from discord import utils, Member, User -from discord.ext import commands from data import constants import requests from functions.database import currency diff --git a/functions/database/commands.py b/functions/database/commands.py index fda1d11..f1bf35c 100644 --- a/functions/database/commands.py +++ b/functions/database/commands.py @@ -1,23 +1,20 @@ +from enum import IntEnum + from functions.database import utils +from functions.stringFormatters import leading_zero as lz import time -def invoked(): +class InvocationType(IntEnum): + TextCommand = 0 + SlashCommand = 1 + ContextMenu = 2 + + +def invoked(inv: InvocationType): t = time.localtime() - day_string: str = f"{t.tm_year}-{_lz(t.tm_mon)}-{_lz(t.tm_mday)}" - _update(day_string) - - -def _lz(arg: int) -> str: - """ - Add leading zeroes if necessary (YYYY-MM-DD) - """ - arg = str(arg) - - if len(arg) == 1: - return f"0{arg}" - - return arg + day_string: str = f"{t.tm_year}-{lz(t.tm_mon)}-{lz(t.tm_mday)}" + _update(day_string, inv) def _is_present(date: str) -> bool: @@ -43,25 +40,27 @@ def _add_date(date: str): connection = utils.connect() cursor = connection.cursor() - cursor.execute("INSERT INTO command_stats(day, amount) VALUES (%s, 1)", (date,)) + cursor.execute("INSERT INTO command_stats(day, commands, slash_commands, context_menus) VALUES (%s, 0, 0, 0)", (date,)) connection.commit() -def _update(date: str): +def _update(date: str, inv: InvocationType): """ Increase the counter for a given day """ - # Date wasn't present yet, add it with a value of 1 + # Date wasn't present yet, add it if not _is_present(date): _add_date(date) - return connection = utils.connect() cursor = connection.cursor() - cursor.execute(""" + column_name = ["commands", "slash_commands", "context_menus"][inv.value] + + # String formatting is safe here because the input comes from above ^ + cursor.execute(f""" UPDATE command_stats - SET amount = amount + 1 + SET {column_name} = {column_name} + 1 WHERE day = %s """, (date,)) connection.commit() diff --git a/functions/eten.py b/functions/eten.py index 30354ce..67ea5d0 100644 --- a/functions/eten.py +++ b/functions/eten.py @@ -1,11 +1,8 @@ -#!/usr/bin/env python3 - import datetime import requests -import sys -def etenScript(weekDag): +def etenScript(weekDag, resto: str = "sterre"): # What day weekdagen = ('ma', 'di', 'wo', 'do', 'vr', 'za', 'zo') deltas = {'morgen': 1, @@ -26,7 +23,7 @@ def etenScript(weekDag): # Fetch from API try: - menu = requests.get(f"https://zeus.ugent.be/hydra/api/2.0/resto/menu/nl-sterre/{d.year}/{d.month}/{d.day}.json").json() + menu = requests.get(f"https://zeus.ugent.be/hydra/api/2.0/resto/menu/nl-{resto}/{d.year}/{d.month}/{d.day}.json").json() if not menu["meals"]: raise Exception() diff --git a/functions/football.py b/functions/football.py index 0648096..ffe2a20 100644 --- a/functions/football.py +++ b/functions/football.py @@ -5,7 +5,7 @@ from datetime import datetime from enum import Enum from functions.timeFormatters import fromString from functions.scrapers.sporza import getJPLMatches, getJPLTable -from functions.stringFormatters import leadingZero +from functions.stringFormatters import leading_zero import re from requests import get import tabulate @@ -39,11 +39,11 @@ class Match: Parse class attributes out of a dictionary returned from an API request """ # The API isn't public, so every single game state is differently formatted - self.status = self._getStatus(self.matchDict[Navigation.Status.value]) + self.status = self._get_status(self.matchDict[Navigation.Status.value]) self.home = self.matchDict[Navigation.HomeTeam.value][Navigation.Name.value] self.away = self.matchDict[Navigation.AwayTeam.value][Navigation.Name.value] - if self._hasStarted(): + if self._has_started(): self.homeScore = self.matchDict[Navigation.HomeScore.value] self.awayScore = self.matchDict[Navigation.AwayScore.value] @@ -53,9 +53,9 @@ class Match: self.start = None self.date = self.start.strftime("%d/%m") if self.start is not None else "Uitgesteld" - self.weekDay = self._getWeekday() if self.start is not None else "??" + self.weekDay = self._get_weekday() if self.start is not None else "??" - def _getStatus(self, status: str): + def _get_status(self, status: str): """ Gets the string representation for the status of this match """ @@ -80,7 +80,7 @@ class Match: return statusses[status.lower()] - def _getWeekday(self): + def _get_weekday(self): """ Gets the day of the week this match is played on """ @@ -88,13 +88,13 @@ class Match: days = ["Ma", "Di", "Wo", "Do", "Vr", "Za", "Zo"] return days[day] - def getInfo(self): + def get_info(self): """ Returns a list of all the info of this class in order to create a table """ - return [self.weekDay, self.date, self.home, self._getScore(), self.away, self.status] + return [self.weekDay, self.date, self.home, self._get_score(), self.away, self.status] - def _getScore(self): + def _get_score(self): """ Returns a string representing the scoreboard """ @@ -102,12 +102,12 @@ class Match: return "??" # No score to show yet, show time when the match starts - if not self._hasStarted(): - return "{}:{}".format(leadingZero(str(self.start.hour)), leadingZero(str(self.start.minute))) + if not self._has_started(): + return "{}:{}".format(leading_zero(str(self.start.hour)), leading_zero(str(self.start.minute))) return "{} - {}".format(self.homeScore, self.awayScore) - def _hasStarted(self): + def _has_started(self): return self.status not in [Status.AfterToday.value, Status.NotStarted.value, Status.Postponed.value] @@ -128,7 +128,7 @@ class Navigation(Enum): Name = "name" -def getMatches(matchweek: int): +def get_matches(matchweek: int): """ Function that constructs the list of matches for a given matchweek """ @@ -139,7 +139,7 @@ def getMatches(matchweek: int): return "Er ging iets fout. Probeer het later opnieuw." matches = list(map(Match, current_day)) - matches = list(map(lambda x: x.getInfo(), matches)) + matches = list(map(lambda x: x.get_info(), matches)) header = "Jupiler Pro League - Speeldag {}".format(matchweek) table = tabulate.tabulate(matches, headers=["Dag", "Datum", "Thuis", "Stand", "Uit", "Tijd"]) @@ -147,7 +147,7 @@ def getMatches(matchweek: int): return "```{}\n\n{}```".format(header, table) -def getTable(): +def get_table(): """ Function that constructs the current table of the JPL """ @@ -157,7 +157,7 @@ def getTable(): return "Er ging iets fout. Probeer het later opnieuw." # Format every row to work for Tabulate - formatted = [_formatRow(row) for row in rows] + formatted = [_format_row(row) for row in rows] header = "Jupiler Pro League Klassement" table = tabulate.tabulate(formatted, headers=["#", "Ploeg", "Punten", "M", "M+", "M-", "M=", "D+", "D-", "D+/-"]) @@ -165,7 +165,7 @@ def getTable(): return "```{}\n\n{}```".format(header, table) -def _formatRow(row): +def _format_row(row): """ Function that formats a row into a list for Tabulate to use """ diff --git a/functions/scrapers/google.py b/functions/scrapers/google.py index 4b7aefa..a6e6546 100644 --- a/functions/scrapers/google.py +++ b/functions/scrapers/google.py @@ -1,9 +1,23 @@ +from typing import List + +import discord from bs4 import BeautifulSoup +from dataclasses import dataclass from requests import get -from urllib.parse import urlencode +from urllib.parse import urlencode, unquote_plus -def google_search(query): +@dataclass +class SearchResult: + status_code: int + query: str + results: List[str] + + def __post_init__(self): + self.query = unquote_plus(self.query[2:]) + + +def google_search(query) -> SearchResult: """ Function to get Google search results """ @@ -17,7 +31,7 @@ def google_search(query): resp = get("https://www.google.com/search?{}&num=20&hl=en".format(query), headers=headers) if resp.status_code != 200: - return None, resp.status_code + return SearchResult(resp.status_code, query, []) bs = BeautifulSoup(resp.text, "html.parser") @@ -28,11 +42,48 @@ def google_search(query): link = element.find("a", href=True) title = element.find("h3") - if link is None or title is None: + if link is None or not link["href"].startswith(("http://", "https://",)) or title is None: return None return link["href"], title.text divs = bs.find_all("div", attrs={"class": "g"}) - return list(getContent(d) for d in divs), 200 + results = list(getContent(d) for d in divs) + + # Filter out Nones + results = list(filter(lambda x: x is not None, results)) + + # Map to urls + links = [] + for (l, t) in results: + links.append(f"[{t}]({l})") + + return SearchResult(200, query, links[:10]) + + +def create_google_embed(result: SearchResult) -> discord.Embed: + embed = discord.Embed(colour=discord.Colour.blue()) + embed.set_author(name="Google Search") + + # Empty list of results + if len(result.results) == 0: + embed.colour = discord.Colour.red() + embed.description = "Geen resultaten gevonden." + return embed + + # Add results into a field + links = [] + + for index, link in enumerate(result.results): + links.append(f"{index + 1}: {link}") + + embed.description = "\n".join(links) + + # Add query into embed + if len(result.query) > 256: + embed.title = result.query[:253] + "..." + else: + embed.title = result.query + + return embed diff --git a/functions/stringFormatters.py b/functions/stringFormatters.py index c38c3c3..f8eb20f 100644 --- a/functions/stringFormatters.py +++ b/functions/stringFormatters.py @@ -1,4 +1,10 @@ -def titleCase(string): +import traceback + +from discord.ext.commands import Context +from dislash import SlashInteraction + + +def title_case(string): return " ".join(capitalize(word) for word in string.split(" ")) @@ -8,8 +14,40 @@ def capitalize(string): return string[0].upper() -def leadingZero(string, size=2): +def leading_zero(string, size=2): string = str(string) while len(string) < size: string = "0" + string return string + + +def format_error_tb(err: Exception) -> str: + # Remove the InvokeCommandError because it's useless information + x = traceback.format_exception(type(err), err, err.__traceback__) + error_string = "" + for line in x: + if "direct cause of the following" in line: + break + error_string += line.replace("*", "") + "\n" if line.strip() != "" else "" + + return error_string + + +def _format_error_location(src) -> str: + DM = src.guild is None + return "DM" if DM else f"{src.channel.name} ({src.guild.name})" + + +def format_command_usage(ctx: Context) -> str: + return f"{ctx.author.display_name} in {_format_error_location(ctx)}: {ctx.message.content}" + + +def format_slash_command_usage(interaction: SlashInteraction) -> str: + # Create a string with the options used + options = " ".join(list(map( + lambda option: f"{option.name}: \"{option.value}\"", + interaction.data.options.values() + ))) + + command = f"{interaction.slash_command.name} {options or ''}" + return f"{interaction.author.display_name} in {_format_error_location(interaction)}: /{command}" diff --git a/functions/timeFormatters.py b/functions/timeFormatters.py index 4460156..1d7788a 100644 --- a/functions/timeFormatters.py +++ b/functions/timeFormatters.py @@ -161,14 +161,14 @@ def fromString(timeString: str, formatString="%d/%m/%Y", tzinfo=pytz.timezone("E def fromArray(data: List[int]) -> datetime: - day = stringFormatters.leadingZero(str(data[0])) - month = stringFormatters.leadingZero(str(data[1])) + day = stringFormatters.leading_zero(str(data[0])) + month = stringFormatters.leading_zero(str(data[1])) year = str(data[2]) if len(data) == 6: - hour = stringFormatters.leadingZero(str(data[3])) - minute = stringFormatters.leadingZero(str(data[4])) - second = stringFormatters.leadingZero(str(data[5])) + hour = stringFormatters.leading_zero(str(data[3])) + minute = stringFormatters.leading_zero(str(data[4])) + second = stringFormatters.leading_zero(str(data[5])) return fromString(f"{day}/{month}/{year} {hour}:{minute}:{second}", formatString="%d/%m/%Y %H:%M:%S") diff --git a/requirements.txt b/requirements.txt index 036bf7e..3f96792 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,7 @@ Quart-CORS==0.5.0 attrs~=21.2.0 dacite~=1.6.0 pytest==6.2.4 -markdownify==0.9.2 \ No newline at end of file +markdownify==0.9.2 + +# Experimental package for slash commands & menus +dislash.py==1.4.9 \ No newline at end of file diff --git a/settings.py b/settings.py index b64e27c..12dd5e6 100644 --- a/settings.py +++ b/settings.py @@ -1,3 +1,5 @@ +from typing import List + from dotenv import load_dotenv import os @@ -31,3 +33,11 @@ TOKEN = os.getenv("TOKEN", "") HOST_IPC = _to_bool(os.getenv("HOSTIPC", "false")) READY_MESSAGE = os.getenv("READYMESSAGE", "I'M READY I'M READY I'M READY I'M READY") # Yes, this is a Spongebob reference STATUS_MESSAGE = os.getenv("STATUSMESSAGE", "with your Didier Dinks.") + +# Guilds to test slash commands in +# Ex: 123,456,789 +SLASH_TEST_GUILDS: List[int] = list( + map(lambda x: int(x), + os.getenv("SLASHTESTGUILDS", "").replace(" ", "").split(",") + ) +) diff --git a/startup/didier.py b/startup/didier.py index 90ddef7..365df73 100644 --- a/startup/didier.py +++ b/startup/didier.py @@ -1,7 +1,8 @@ from data.snipe import Snipe from discord.ext import commands, ipc +from dislash import InteractionClient import os -from settings import HOST_IPC +from settings import HOST_IPC, SLASH_TEST_GUILDS from startup.init_files import check_all from typing import Dict @@ -10,6 +11,8 @@ class Didier(commands.Bot): """ Main Bot class for Didier """ + # Reference to interactions client + interactions: InteractionClient # Dict to store the most recent Snipe info per channel snipe: Dict[int, Snipe] = {} @@ -29,6 +32,9 @@ class Didier(commands.Bot): # Remove default help command self.remove_command("help") + # Create interactions client + self.interactions = InteractionClient(self, test_guilds=SLASH_TEST_GUILDS) + # Load all extensions self.init_extensions() @@ -41,9 +47,24 @@ class Didier(commands.Bot): self.load_extension(f"cogs.{ext}") # Load all remaining cogs - for file in os.listdir("./cogs"): - if file.endswith(".py") and not (file.startswith(self._preload)): - self.load_extension("cogs.{}".format(file[:-3])) + self._init_directory("./cogs") + + def _init_directory(self, path: str): + """ + Load all cogs from a directory + """ + # Path to pass into load_extension + load_path = path[2:].replace("/", ".") + + for file in os.listdir(path): + # Python file + if file.endswith(".py"): + if not file.startswith(self._preload): + self.load_extension(f"{load_path}.{file[:-3]}") + elif os.path.isdir(new_path := f"{path}/{file}"): + # Subdirectory + # Also walrus operator hype + self._init_directory(new_path) async def on_ipc_ready(self): print("IPC server is ready.") diff --git a/tests/data/embeds/__init__.py b/tests/data/embeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/embeds/test_urban_dictionary.py b/tests/data/embeds/test_urban_dictionary.py new file mode 100644 index 0000000..2707869 --- /dev/null +++ b/tests/data/embeds/test_urban_dictionary.py @@ -0,0 +1,45 @@ +from data.embeds.urban_dictionary import Definition +import unittest + + +class TestUD(unittest.TestCase): + def test_clean_string(self): + self.assertEqual( + Definition.clean_string("A definition [with links] to other [definitions]"), + "A definition with links to other definitions" + ) + + no_processing = "A string that needs no processing." + self.assertEqual(Definition.clean_string(no_processing), no_processing) + + long_string = "A very long string that hopefully exceeds the 1024 character limit for embed field values, " \ + "in order to test if the truncation part of this specific function works as expected. " \ + "The issue is that coming up with a string that exceeds the 1024 embed field value character " \ + "limit is quite tedious, so I have no idea how I plan on ever finishing this." \ + "As of the writing of this sentence, I'm only a third of the way there." \ + "Crazy. I could probably just toss some lorem ipsum in there, but that would be no fun." \ + "Or would it? Hey GitHub, Didier here." \ + "Instead I would like to take this opportunity to out my frustrations on the abomination of a " \ + "\"language\" that is Haskell. You see, Haskell is just terrible and useless. " \ + "It truly does pose the bane of my existence, and I deeply hope that I will never have to use it " \ + "ever again in my life. Good thing I somehow managed to pass that class, otherwise I would've " \ + "probably collapsed mentally on the spot. As it turns out, though, this sentence is already in the " \ + "900 character range, so I don't have much of a reason to continue writing about the worst " \ + "invention humanity has ever come up with." + + self.assertGreater(len(long_string), 1024) + self.assertEqual(len(Definition.clean_string(long_string)), 1024) + self.assertEqual(Definition.clean_string(long_string)[-3:], "...") + + def test_ratio(self): + dic = { + "thumbs_up": 5, + "thumbs_down": 0 + } + self.assertEqual(Definition.ratio(dic), 100.0) + + dic["thumbs_down"] = 5 + self.assertEqual(Definition.ratio(dic), 50.0) + + dic["thumbs_up"] = 0 + self.assertEqual(Definition.ratio(dic), 0)