diff --git a/.gitignore b/.gitignore index df1d76e..9b3c80f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,11 @@ -files/status.txt -files/readyMessage.txt -files/client.txt files/lastTasks.json files/c4.json files/hangman.json files/stats.json files/lost.json files/locked.json -files/database.json files/ufora_notifications.json .idea/ __pycache__ -.env \ No newline at end of file +.env +/venv/ diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..3f76518 --- /dev/null +++ b/backend/server.py @@ -0,0 +1,82 @@ +from discord.ext import ipc +from functions.database import custom_commands +import json +from quart import Quart, jsonify, request +from quart_cors import cors +from time import time + + +app = Quart(__name__) +# TODO allow_origin=re.compile(r"http://localhost:.*") +# needs higher Python & Quart version +app = cors(app, allow_origin="*") +app.config.from_object(__name__) + + +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 + """ + latency = await ipc_client.request("get_bot_latency") + + return jsonify({"bot_latency": latency, "response_sent": time()}) + + +@app.route("/dm", methods=["POST"]) +async def send_dm(): + """ + Send a DM to the given user + """ + data = json.loads((await request.body).decode('UTF-8')) + + dm = await ipc_client.request( + "send_dm", + user=int(data["userid"]), + message=data.get("message") + ) + + return jsonify({"response": dm}) + + +@app.route("/custom", methods=["GET"]) +async def get_all_custom_commands(): + """ + Return a list of all custom commands in the bot + """ + commands = custom_commands.get_all() + + return jsonify(commands) + + +@app.route("/custom/") +async def get_custom_command(command_id): + try: + command_id = int(command_id) + except ValueError: + # Id is not an int + return unprocessable_entity("Parameter id was not a valid integer.") + + command = custom_commands.get_by_id(command_id) + + if command is None: + return page_not_found("") + + return jsonify(command) + + +@app.errorhandler(404) +def page_not_found(e): + return jsonify({"error": "No resource could be found matching the given URL."}), 404 + + +@app.errorhandler(422) +def unprocessable_entity(e): + return jsonify({"error": e}), 422 + + +if __name__ == "__main__": + app.run() diff --git a/cogs/events.py b/cogs/events.py index 238cadc..7e95bb9 100644 --- a/cogs/events.py +++ b/cogs/events.py @@ -5,6 +5,7 @@ from discord.ext import commands from functions import checks, easterEggResponses from functions.database import stats, muttn, custom_commands, commands as command_stats import pytz +from settings import READY_MESSAGE, SANDBOX, STATUS_MESSAGE import time import traceback @@ -30,20 +31,13 @@ class Events(commands.Cog): """ Function called when the bot is ready & done leading. """ - # Change status - with open("files/status.txt", "r") as statusFile: - status = statusFile.readline() + # Set status + await self.client.change_presence(status=discord.Status.online, activity=discord.Game(STATUS_MESSAGE)) - await self.client.change_presence(status=discord.Status.online, activity=discord.Game(str(status))) - - # Print a message in the terminal to show that he's ready - with open("files/readyMessage.txt", "r") as readyFile: - readyMessage = readyFile.readline() - - print(readyMessage) + print(READY_MESSAGE) # Add constants to the client as a botvar - self.client.constants = constants.Live if "zandbak" not in readyMessage else constants.Zandbak + self.client.constants = constants.Live if SANDBOX else constants.Zandbak @commands.Cog.listener() async def on_message(self, message): diff --git a/cogs/ipc.py b/cogs/ipc.py new file mode 100644 index 0000000..1e3c394 --- /dev/null +++ b/cogs/ipc.py @@ -0,0 +1,26 @@ +from discord.ext import commands, ipc + + +class IPC(commands.Cog): + def __init__(self, client): + self.client = client + + @ipc.server.route() + async def send_dm(self, data): + print("got here") + user = self.client.get_user(data.user) + await user.send(data.message) + print("sent") + return True + + @ipc.server.route() + async def get_bot_latency(self, data): + """ + Get Didier's latency + """ + + return self.client.latency * 1000 + + +def setup(client): + client.add_cog(IPC(client)) diff --git a/cogs/tasks.py b/cogs/tasks.py index 4ac974d..2c4818a 100644 --- a/cogs/tasks.py +++ b/cogs/tasks.py @@ -246,7 +246,7 @@ class Tasks(commands.Cog): Task that checks for new Ufora announcements every few minutes """ # Don't run this when testing - if self.client.user.id == int(constants.coolerDidierId): + if self.client.user.id != int(constants.didierId): return # Get new notifications diff --git a/cogs/translate.py b/cogs/translate.py index 7330673..e607ca8 100644 --- a/cogs/translate.py +++ b/cogs/translate.py @@ -15,8 +15,8 @@ class Translate(commands.Cog): def cog_check(self, ctx): return not self.client.locked - # @commands.command(name="Translate", aliases=["Tl", "Trans"], usage="[Tekst] [Van]* [Naar]*") - # @help.Category(Category.Words) + @commands.command(name="Translate", aliases=["Tl", "Trans"], usage="[Tekst] [Van]* [Naar]*") + @help.Category(Category.Words) async def translate(self, ctx, query=None, to="nl", fr="auto"): if query is None: return await ctx.send("Controleer je argumenten.") @@ -39,9 +39,11 @@ class Translate(commands.Cog): embed.set_author(name="Didier Translate") if fr == "auto": - language = translation.extra_data["original-language"] + language = translation.src embed.add_field(name="Gedetecteerde taal", value=tc(LANGUAGES[language])) - embed.add_field(name="Zekerheid", value="{}%".format(translation.extra_data["confidence"] * 100)) + + if translation.extra_data["confidence"] is not None: + embed.add_field(name="Zekerheid", value="{}%".format(translation.extra_data["confidence"] * 100)) embed.add_field(name="Origineel ({})".format(translation.src.upper()), value=query, inline=False) embed.add_field(name="Vertaling ({})".format(to.upper()), value=translation.text) diff --git a/didier.py b/didier.py index 89bb5cb..551161e 100644 --- a/didier.py +++ b/didier.py @@ -1,33 +1,19 @@ import discord -from discord.ext import commands from dotenv import load_dotenv from functions.prefixes import get_prefix -import os +from settings import TOKEN +from startup.didier import Didier +if __name__ == "__main__": + load_dotenv(verbose=True) -load_dotenv(verbose=True) + # Configure intents (1.5.0) + intents = discord.Intents.default() + intents.members = True + client = Didier(command_prefix=get_prefix, case_insensitive=True, intents=intents) -# Configure intents (1.5.0) -intents = discord.Intents.default() -intents.members = True + if client.ipc is not None: + client.ipc.start() -client = commands.Bot(command_prefix=get_prefix, case_insensitive=True, intents=intents) - -# Remove default help because it sucks & I made my own -client.remove_command("help") - -# Load utils first so it can be used in other places & it's not None -client.load_extension("cogs.utils") -client.load_extension("cogs.failedchecks") -client.load_extension("cogs.events") - -# Load all remaining cogs -for file in os.listdir("./cogs"): - if file.endswith(".py") and not (file.startswith(("utils", "failedchecks", "events"),)): - client.load_extension("cogs.{}".format(file[:-3])) - -# Get the token out of the file & run the bot -with open("files/client.txt", "r") as fp: - token = fp.readline() -client.run(token) + client.run(TOKEN) diff --git a/faq.md b/faq.md index c7ba0a2..e906dbd 100644 --- a/faq.md +++ b/faq.md @@ -1,5 +1,14 @@ # FAQ -Answers to Frequently Asked Questions. +Answers to Frequently Asked Questions and solutions to issues that may arise. -### Table of Contents -A list of all questions (in order) so you can easily find what you're looking for. +## Issues installing dotenv + +The name of this package is `python-dotenv`, as listed in `requirements.txt`. The _name_ of the package, however, is just `dotenv`. This confuses PyCharm, which will tell you that you don't have `dotenv` installed as it can't link those two together. You can just click `ignore this requirement` if you don't like the warning. + +## Issues installing psycopg2 + +The `psychopg2` and `psychopg2-binary` packages might cause you some headaches when trying to install them, especially when using PyCharm to install dependencies. The reason for this is because these are `PSQL` packages, and as a result they require you to have `PSQL` installed on your system to function properly. + +## Package is not listed in project requirements + +This is the exact same case as [Issues installing dotenv](#issues-installing-dotenv), and occurs for packages such as `Quart`. The names of the modules differ from the name used to install it from `pip`. Once again, you can ignore this message. \ No newline at end of file diff --git a/files/default/hangman.json b/files/default/hangman.json new file mode 100644 index 0000000..6c20ddf --- /dev/null +++ b/files/default/hangman.json @@ -0,0 +1,5 @@ +{ + "guessed": [], + "guesses": 0, + "word": "" +} \ No newline at end of file diff --git a/files/default/lastTasks.json b/files/default/lastTasks.json new file mode 100644 index 0000000..bcf16cc --- /dev/null +++ b/files/default/lastTasks.json @@ -0,0 +1,9 @@ +{ + "interest": 0, + "lost": 0, + "poke": 0, + "prison": 0, + "birthdays": 0, + "channels": 0, + "remind": 0 +} \ No newline at end of file diff --git a/files/default/locked.json b/files/default/locked.json new file mode 100644 index 0000000..4b7fab0 --- /dev/null +++ b/files/default/locked.json @@ -0,0 +1,4 @@ +{ + "locked": false, + "until": -1 +} \ No newline at end of file diff --git a/files/default/lost.json b/files/default/lost.json new file mode 100644 index 0000000..57e53fe --- /dev/null +++ b/files/default/lost.json @@ -0,0 +1,4 @@ +{ + "lost": 0, + "today": 0 +} \ No newline at end of file diff --git a/files/default/stats.json b/files/default/stats.json new file mode 100644 index 0000000..d3ef932 --- /dev/null +++ b/files/default/stats.json @@ -0,0 +1,19 @@ +{ + "cf": { + "h": 0, + "t": 0 + }, + "dice": { + "2": 0, + "5": 0, + "3": 0, + "6": 0, + "1": 0, + "4": 0 + }, + "rob": { + "robs_success": 0, + "robs_failed": 0, + "bail_paid": 0.0 + } +} \ No newline at end of file diff --git a/files/default/ufora_notifications.json b/files/default/ufora_notifications.json new file mode 100644 index 0000000..0aa02a4 --- /dev/null +++ b/files/default/ufora_notifications.json @@ -0,0 +1,12 @@ +{ + "Algoritmen en Datastructuren 2": [], + "Communicatienetwerken": [], + "Computerarchitectuur": [], + "Functioneel Programmeren": [], + "Multimedia": [], + "Software Engineering Lab 1": [], + "Statistiek en Probabiliteit": [], + "Systeemprogrammeren": [], + "Webdevelopment": [], + "Wetenschappelijk Rekenen": [] +} \ No newline at end of file diff --git a/functions/database/custom_commands.py b/functions/database/custom_commands.py index ea8f172..7f0147a 100644 --- a/functions/database/custom_commands.py +++ b/functions/database/custom_commands.py @@ -127,3 +127,55 @@ def add_alias(command: str, alias: str): cursor.execute("INSERT INTO custom_command_aliases(command, alias) VALUES(%s, %s)", (command_id, alias,)) connection.commit() + + +def get_all(): + """ + Return a list of all registered custom commands + """ + connection = utils.connect() + cursor = connection.cursor() + + cursor.execute("SELECT * FROM custom_commands") + commands = cursor.fetchall() + ret = [] + + # Create a list of all entries + for command in commands: + dic = {"id": command[0], "name": command[1], "response": command[2]} + + # Find and add aliases + cursor.execute("SELECT id, alias FROM custom_command_aliases WHERE command = %s", (command[0],)) + aliases = cursor.fetchall() + + if aliases: + dic["aliases"] = list(map(lambda x: {"id": x[0], "alias": x[1]}, aliases)) + + ret.append(dic) + + return ret + + +def get_by_id(command_id: int): + """ + Return a command that matches a given id + """ + connection = utils.connect() + cursor = connection.cursor() + + cursor.execute("SELECT * FROM custom_commands WHERE id = %s", (command_id,)) + command = cursor.fetchone() + + # Nothing found + if not command: + return None + + dic = {"id": command[0], "name": command[1], "response": command[2]} + + cursor.execute("SELECT id, alias FROM custom_command_aliases WHERE command = %s", (command_id,)) + aliases = cursor.fetchall() + + if aliases: + dic["aliases"] = list(map(lambda x: {"id": x[0], "alias": x[1]}, aliases)) + + return dic diff --git a/functions/database/utils.py b/functions/database/utils.py index 828e198..d4c7b32 100644 --- a/functions/database/utils.py +++ b/functions/database/utils.py @@ -1,6 +1,5 @@ import psycopg2 -import json -import os +from settings import DB_HOST, DB_NAME, DB_USERNAME, DB_PASSWORD connection = None @@ -17,15 +16,11 @@ def connect(): def create_connection(): global connection - dir_path = os.path.dirname(os.path.realpath(__file__)) - with open(os.path.join(dir_path, "../../files/database.json"), "r") as fp: - db = json.load(fp) - connection = psycopg2.connect( - host=db["host"], - database=db["database"], - user=db["username"], - password=db["password"] + host=DB_HOST, + database=DB_NAME, + user=DB_USERNAME, + password=DB_PASSWORD ) diff --git a/ignored.md b/ignored.md index 9ce1bdf..ce73183 100644 --- a/ignored.md +++ b/ignored.md @@ -2,28 +2,7 @@ A list of all ignored files with copy-pastable templates. Useful for when you want to work on commands that use these, for obvious reasons. Every file has a copy-pastable template to make it easy for you to use. -These are usually files which would be overkill to make a PSQL table for. Other possibilities are files that are never edited, but should be different on every machine (Discord token, status message, ...). - -### files/client.txt - -Contains the application's token to connect to Discord. You can create your own bot & put it's token in this file to run & test Didier code. - - token_goes_here - -### files/database.json - -Contains the credentials needed to connect to the PSQL database. This is ignored so that I don't have to leak my IP address, but also so that you can set up a local database to mess around without affecting the Live one or having to change any code. - -```json -{ - "username": "username", - "password": "password", - "host": "host_address", - "database": "database_name" -} -``` - -When connecting to a local PSQL database, `host` should be `"localhost"`. +These are usually files which would be overkill to make a PSQL table for. Other possibilities are files that are never edited, but should be different on every machine. ### files/hangman.json @@ -64,14 +43,6 @@ Contains a boolean indicating whether or not the server is currently locked, and } ``` -### files/readyMessage.txt - -Contains the message printed in your terminal when Didier is ready. - - I'M READY I'M READY I'M READY I'M READY - -In case you were wondering: yes, this is a Spongebob reference. - ### files/stats.json Contains the stats to track for gambling games. Weren't made as a PSQL table because they would be too long (and every game is different). @@ -98,12 +69,6 @@ Contains the stats to track for gambling games. Weren't made as a PSQL table bec } ``` -### files/status.txt - -Contains Didier's status message for when he logs in. Keep in mind that his activity is set to `Playing `. This was first used in Didier V1 to show whether or not he was in sandbox mode. - - with your Didier Dinks. - ### files/ufora_notifications.json Stores ID's of all received Ufora notifications. diff --git a/readme.md b/readme.md index 5155cb9..76e5796 100644 --- a/readme.md +++ b/readme.md @@ -51,13 +51,16 @@ When creating a new Didier command, you can add it to a `Category` by adding a d ```python from decorators import help +from discord.ext import commands from enums.help_categories import Category +from functions import checks @commands.command(name="Command Name", aliases=["Cn"]) @commands.check(checks.allowedChannels) @help.Category(Category.Currency) async def command_name(self, ctx): # Command code + await ctx.send("Command response") ``` This allows commands across multiple Cogs to be classified under the same category in the help page. diff --git a/requirements.txt b/requirements.txt index 06ba00f..2980dd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ python-dotenv==0.14.0 beautifulsoup4==4.9.1 -discord.py==1.7.0 +discord.py==1.7.3 +git+https://github.com/Rapptz/discord-ext-menus@master +discord-ext-ipc==2.0.0 psycopg2==2.8.5 psycopg2-binary==2.8.5 python-dateutil==2.6.1 @@ -10,4 +12,6 @@ requests-unixsocket==0.1.5 tabulate==0.8.7 yarl==1.4.2 feedparser==6.0.2 -googletrans==3.0.0 \ No newline at end of file +googletrans==4.0.0rc1 +quart==0.6.15 +Quart-CORS==0.1.3 \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..b64e27c --- /dev/null +++ b/settings.py @@ -0,0 +1,33 @@ +from dotenv import load_dotenv +import os + + +load_dotenv() + + +def _to_bool(value: str) -> bool: + """ + Env variables are strings so this converts them to booleans + """ + return value.lower() in ["true", "1", "y", "yes"] + + +# Sandbox or live +SANDBOX = _to_bool(os.getenv("SANDBOX", "true")) + +# Tokens & API keys +URBANDICTIONARY = os.getenv("URBANDICTIONARY", "") +IMGFLIP_NAME = os.getenv("IMGFLIPNAME", "") +IMGFLIP_PASSWORD = os.getenv("IMGFLIPPASSWORD", "") + +# Database credentials +DB_USERNAME = os.getenv("DBUSERNAME", "") +DB_PASSWORD = os.getenv("DBPASSWORD", "") +DB_HOST = os.getenv("DBHOST", "") +DB_NAME = os.getenv("DBNAME", "") + +# Discord-related +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.") diff --git a/startup/__init__.py b/startup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/startup/didier.py b/startup/didier.py new file mode 100644 index 0000000..9cd9874 --- /dev/null +++ b/startup/didier.py @@ -0,0 +1,46 @@ +from discord.ext import commands, ipc +from settings import HOST_IPC +from startup.init_files import check_all +import os + + +class Didier(commands.Bot): + """ + Main Bot class for Didier + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._host_ipc = HOST_IPC + + # IPC Server + # TODO secret key + self.ipc = ipc.Server(self, secret_key="SOME_SECRET_KEY") if self._host_ipc else None + + # Cogs that should be loaded before the others + self._preload = ("ipc", "utils", "failedchecks", "events",) + + # Remove default help command + self.remove_command("help") + + # Load all extensions + self.init_extensions() + + # Check missing files + check_all() + + def init_extensions(self): + # Load initial extensions + for ext in self._preload: + 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])) + + async def on_ipc_ready(self): + print("IPC server is ready.") + + async def on_ipc_error(self, endpoint, error): + print(endpoint, "raised", error) diff --git a/startup/init_files.py b/startup/init_files.py new file mode 100644 index 0000000..451ad0a --- /dev/null +++ b/startup/init_files.py @@ -0,0 +1,13 @@ +import json +from os import path + + +def check_all(): + files = ["hangman", "lastTasks", "locked", "lost", "stats", "ufora_notifications"] + + for f in files: + if not path.isfile(path.join(f"files/{f}.json")): + with open(f"files/{f}.json", "w+") as new_file, open(f"files/default/{f}.json", "r") as default: + content = json.load(default) + json.dump(content, new_file) + print(f"Created missing file: files/{f}.json")