From 6583e7ccbbbdff86d02d85155ad091706cd15171 Mon Sep 17 00:00:00 2001 From: neoarz Date: Sun, 14 Sep 2025 16:09:43 -0400 Subject: [PATCH] Initial commit --- .dockerignore | 8 + .env.example | 3 + .gitignore | 147 +++++++++++++++++ Dockerfile | 8 + LICENSE.md | 144 +++++++++++++++++ README.md | 141 ++++++++++++++++ bot.py | 289 +++++++++++++++++++++++++++++++++ cogs/fun.py | 163 +++++++++++++++++++ cogs/general.py | 327 +++++++++++++++++++++++++++++++++++++ cogs/moderation.py | 375 +++++++++++++++++++++++++++++++++++++++++++ cogs/owner.py | 220 +++++++++++++++++++++++++ cogs/template.py | 38 +++++ database/__init__.py | 96 +++++++++++ database/schema.sql | 8 + docker-compose.yml | 15 ++ requirements.txt | 4 + 16 files changed, 1986 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 bot.py create mode 100644 cogs/fun.py create mode 100644 cogs/general.py create mode 100644 cogs/moderation.py create mode 100644 cogs/owner.py create mode 100644 cogs/template.py create mode 100644 database/__init__.py create mode 100644 database/schema.sql create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..562da7a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git/ +.github/ +database/ +venv/ +.env* +CODE_OF_CONDUCT.md +CONTRIBUTING.md +UPDATES.md \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5d8f5b2 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +TOKEN=YOUR_BOT_TOKEN_HERE +PREFIX=YOUR_BOT_PREFIX_HERE +INVITE_LINK=YOUR_BOT_INVITE_LINK_HERE \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7676f1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,147 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm IDEA +.idea/* + +# SQLITE database +*.db + +# Log file +discord.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..028e726 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12.9-slim-bookworm + +WORKDIR /bot +COPY . /bot + +RUN python -m pip install -r requirements.txt + +ENTRYPOINT [ "python", "bot.py" ] \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..3ae1512 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,144 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 + through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or + are under common control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by + contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) + beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, including but not limited to software source + code, documentation source, and configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including + but not limited to compiled object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as + indicated by a copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work + and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an + original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or + additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the + Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright + owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including + but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems + that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as " + Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been + received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to + You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, + prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such + Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a + perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise + transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are + necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity ( + including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within + the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this + License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with + or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, + trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You + distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those + notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a + NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided + along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such + third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not + modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be + construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms + and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a + whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in + this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for + inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any + additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any + separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product + names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and + reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and + each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, + MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness + of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this + License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or + otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, + shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or + consequential damages of any character arising as a result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or + any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such + damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose + to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or + rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and + on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and + hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Krypton + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an " +AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6c7c75 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# Python Discord Bot Template + +

+ + + + + + + +

+ +> [!NOTE] +> This project is in a **feature-freeze mode**, please read more about it [here](https://github.com/kkrypt0nn/Python-Discord-Bot-Template/issues/112). It can be summed up in a few bullet points: +> +> * The project **will** receive bug fixes +> * The project **will** be updated to make sure it works with the **latest** discord.py version +> * The project **will not** receive any new features, **unless one of the following applies**: +> * A new feature is added to Discord and it would be beneficial to have it in the template +> * A feature got a breaking change, this fits with the same point that the project will **always** support the latest discord.py version + +This repository is a template that everyone can use for the start of their Discord bot. + +When I first started creating my Discord bot it took me a while to get everything setup and working with cogs and more. +I would've been happy if there were any template existing. However, there wasn't any existing template. That's why I +decided to create my own template to let **you** guys create your Discord bot easily. + +Please note that this template is not supposed to be the best template, but a good template to start learning how +discord.py works and to make your own bot easily. + +If you plan to use this template to make your own template or bot, you **have to**: + +- Keep the credits, and a link to this repository in all the files that contains my code +- Keep the same license for unchanged code + +See [the license file](https://github.com/kkrypt0nn/Python-Discord-Bot-Template/blob/master/LICENSE.md) for more +information, I reserve the right to take down any repository that does not meet these requirements. + +## Support + +Before requesting support, you should know that this template requires you to have at least a **basic knowledge** of +Python and the library is made for **advanced users**. Do not use this template if you don't know the +basics or some advanced topics such as OOP or async. [Here's](https://pythondiscord.com/pages/resources) a link for resources to learn python. + +If you need some help for something, do not hesitate to create an issue over [here](https://github.com/kkrypt0nn/Python-Discord-Bot-Template/issues), but don't forget the read the [frequently asked questions](https://github.com/kkrypt0nn/Python-Discord-Bot-Template/wiki/Frequently-Asked-Questions) before. + +All the updates of the template are available [here](UPDATES.md). + +## Disclaimer + +Slash commands can take some time to get registered globally, so if you want to test a command you should use +the `@app_commands.guilds()` decorator so that it gets registered instantly. Example: + +```py +@commands.hybrid_command( + name="command", + description="Command description", +) +@app_commands.guilds(discord.Object(id=GUILD_ID)) # Place your guild ID here +``` + +When using the template you confirm that you have read the [license](LICENSE.md) and comprehend that I can take down +your repository if you do not meet these requirements. + +## How to download it + +This repository is now a template, on the top left you can simply click on "**Use this template**" to create a GitHub +repository based on this template. + +Alternatively you can do the following: + +- Clone/Download the repository + - To clone it and get the updates you can definitely use the command + `git clone` +- Create a Discord bot [here](https://discord.com/developers/applications) +- Get your bot token +- Invite your bot on servers using the following invite: + https://discord.com/oauth2/authorize?&client_id=YOUR_APPLICATION_ID_HERE&scope=bot+applications.commands&permissions=PERMISSIONS ( + Replace `YOUR_APPLICATION_ID_HERE` with the application ID and replace `PERMISSIONS` with the required permissions + your bot needs that it can be get at the bottom of a this + page https://discord.com/developers/applications/YOUR_APPLICATION_ID_HERE/bot) + +## How to set up + +To set up the token you will have to make use of the [`.env.example`](.env.example) file; you should rename it to `.env` and replace the `YOUR_BOT...` content with your actual values that match for your bot. + +Alternatively you can simply create a system environment variable with the same names and their respective value. + +## How to start + +### The _"usual"_ way + +To start the bot you simply need to launch, either your terminal (Linux, Mac & Windows), or your Command Prompt ( +Windows) +. + +Before running the bot you will need to install all the requirements with this command: + +``` +python -m pip install -r requirements.txt +``` + +After that you can start it with + +``` +python bot.py +``` + +> **Note**: You may need to replace `python` with `py`, `python3`, `python3.11`, etc. depending on what Python versions you have installed on the machine. + +### Docker + +Support to start the bot in a Docker container has been added. After having [Docker](https://docker.com) installed on your machine, you can simply execute: + +``` +docker compose up -d --build +``` + +> **Note**: `-d` will make the container run in detached mode, so in the background. + +## Issues or Questions + +If you have any issues or questions of how to code a specific command, you can: + +- Join my Discord server [here](https://discord.gg/xj6y5ZaTMr) +- Post them [here](https://github.com/kkrypt0nn/Python-Discord-Bot-Template/issues) + +Me or other people will take their time to answer and help you. + +## Versioning + +We use [SemVer](http://semver.org) for versioning. For the versions available, see +the [tags on this repository](https://github.com/kkrypt0nn/Python-Discord-Bot-Template/tags). + +## Built With + +- [Python 3.12.9](https://www.python.org/) + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE.md](LICENSE.md) file for details diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..e32d8cc --- /dev/null +++ b/bot.py @@ -0,0 +1,289 @@ +""" +Copyright ยฉ Krypton 2019-Present - https://github.com/kkrypt0nn (https://krypton.ninja) +Description: +๐Ÿ A simple template to start to code your own and personalized Discord bot in Python + +Version: 6.4.0 +""" + +import json +import logging +import os +import platform +import random +import sys + +import aiosqlite +import discord +from discord.ext import commands, tasks +from discord.ext.commands import Context +from dotenv import load_dotenv + +from database import DatabaseManager + +load_dotenv() + +""" +Setup bot intents (events restrictions) +For more information about intents, please go to the following websites: +https://discordpy.readthedocs.io/en/latest/intents.html +https://discordpy.readthedocs.io/en/latest/intents.html#privileged-intents + + +Default Intents: +intents.bans = True +intents.dm_messages = True +intents.dm_reactions = True +intents.dm_typing = True +intents.emojis = True +intents.emojis_and_stickers = True +intents.guild_messages = True +intents.guild_reactions = True +intents.guild_scheduled_events = True +intents.guild_typing = True +intents.guilds = True +intents.integrations = True +intents.invites = True +intents.messages = True # `message_content` is required to get the content of the messages +intents.reactions = True +intents.typing = True +intents.voice_states = True +intents.webhooks = True + +Privileged Intents (Needs to be enabled on developer portal of Discord), please use them only if you need them: +intents.members = True +intents.message_content = True +intents.presences = True +""" + +intents = discord.Intents.default() + +""" +Uncomment this if you want to use prefix (normal) commands. +It is recommended to use slash commands and therefore not use prefix commands. + +If you want to use prefix commands, make sure to also enable the intent below in the Discord developer portal. +""" +# intents.message_content = True + +# Setup both of the loggers + + +class LoggingFormatter(logging.Formatter): + # Colors + black = "\x1b[30m" + red = "\x1b[31m" + green = "\x1b[32m" + yellow = "\x1b[33m" + blue = "\x1b[34m" + gray = "\x1b[38m" + # Styles + reset = "\x1b[0m" + bold = "\x1b[1m" + + COLORS = { + logging.DEBUG: gray + bold, + logging.INFO: blue + bold, + logging.WARNING: yellow + bold, + logging.ERROR: red, + logging.CRITICAL: red + bold, + } + + def format(self, record): + log_color = self.COLORS[record.levelno] + format = "(black){asctime}(reset) (levelcolor){levelname:<8}(reset) (green){name}(reset) {message}" + format = format.replace("(black)", self.black + self.bold) + format = format.replace("(reset)", self.reset) + format = format.replace("(levelcolor)", log_color) + format = format.replace("(green)", self.green + self.bold) + formatter = logging.Formatter(format, "%Y-%m-%d %H:%M:%S", style="{") + return formatter.format(record) + + +logger = logging.getLogger("discord_bot") +logger.setLevel(logging.INFO) + +# Console handler +console_handler = logging.StreamHandler() +console_handler.setFormatter(LoggingFormatter()) +# File handler +file_handler = logging.FileHandler(filename="discord.log", encoding="utf-8", mode="w") +file_handler_formatter = logging.Formatter( + "[{asctime}] [{levelname:<8}] {name}: {message}", "%Y-%m-%d %H:%M:%S", style="{" +) +file_handler.setFormatter(file_handler_formatter) + +# Add the handlers +logger.addHandler(console_handler) +logger.addHandler(file_handler) + + +class DiscordBot(commands.Bot): + def __init__(self) -> None: + super().__init__( + command_prefix=commands.when_mentioned_or(os.getenv("PREFIX")), + intents=intents, + help_command=None, + ) + """ + This creates custom bot variables so that we can access these variables in cogs more easily. + + For example, The logger is available using the following code: + - self.logger # In this class + - bot.logger # In this file + - self.bot.logger # In cogs + """ + self.logger = logger + self.database = None + self.bot_prefix = os.getenv("PREFIX") + self.invite_link = os.getenv("INVITE_LINK") + + async def init_db(self) -> None: + async with aiosqlite.connect( + f"{os.path.realpath(os.path.dirname(__file__))}/database/database.db" + ) as db: + with open( + f"{os.path.realpath(os.path.dirname(__file__))}/database/schema.sql", + encoding = "utf-8" + ) as file: + await db.executescript(file.read()) + await db.commit() + + async def load_cogs(self) -> None: + """ + The code in this function is executed whenever the bot will start. + """ + for file in os.listdir(f"{os.path.realpath(os.path.dirname(__file__))}/cogs"): + if file.endswith(".py"): + extension = file[:-3] + try: + await self.load_extension(f"cogs.{extension}") + self.logger.info(f"Loaded extension '{extension}'") + except Exception as e: + exception = f"{type(e).__name__}: {e}" + self.logger.error( + f"Failed to load extension {extension}\n{exception}" + ) + + @tasks.loop(minutes=1.0) + async def status_task(self) -> None: + """ + Setup the game status task of the bot. + """ + statuses = ["with you!", "with Krypton!", "with humans!"] + await self.change_presence(activity=discord.Game(random.choice(statuses))) + + @status_task.before_loop + async def before_status_task(self) -> None: + """ + Before starting the status changing task, we make sure the bot is ready + """ + await self.wait_until_ready() + + async def setup_hook(self) -> None: + """ + This will just be executed when the bot starts the first time. + """ + self.logger.info(f"Logged in as {self.user.name}") + self.logger.info(f"discord.py API version: {discord.__version__}") + self.logger.info(f"Python version: {platform.python_version()}") + self.logger.info( + f"Running on: {platform.system()} {platform.release()} ({os.name})" + ) + self.logger.info("-------------------") + await self.init_db() + await self.load_cogs() + self.status_task.start() + self.database = DatabaseManager( + connection=await aiosqlite.connect( + f"{os.path.realpath(os.path.dirname(__file__))}/database/database.db" + ) + ) + + async def on_message(self, message: discord.Message) -> None: + """ + The code in this event is executed every time someone sends a message, with or without the prefix + + :param message: The message that was sent. + """ + if message.author == self.user or message.author.bot: + return + await self.process_commands(message) + + async def on_command_completion(self, context: Context) -> None: + """ + The code in this event is executed every time a normal command has been *successfully* executed. + + :param context: The context of the command that has been executed. + """ + full_command_name = context.command.qualified_name + split = full_command_name.split(" ") + executed_command = str(split[0]) + if context.guild is not None: + self.logger.info( + f"Executed {executed_command} command in {context.guild.name} (ID: {context.guild.id}) by {context.author} (ID: {context.author.id})" + ) + else: + self.logger.info( + f"Executed {executed_command} command by {context.author} (ID: {context.author.id}) in DMs" + ) + + async def on_command_error(self, context: Context, error) -> None: + """ + The code in this event is executed every time a normal valid command catches an error. + + :param context: The context of the normal command that failed executing. + :param error: The error that has been faced. + """ + if isinstance(error, commands.CommandOnCooldown): + minutes, seconds = divmod(error.retry_after, 60) + hours, minutes = divmod(minutes, 60) + hours = hours % 24 + embed = discord.Embed( + description=f"**Please slow down** - You can use this command again in {f'{round(hours)} hours' if round(hours) > 0 else ''} {f'{round(minutes)} minutes' if round(minutes) > 0 else ''} {f'{round(seconds)} seconds' if round(seconds) > 0 else ''}.", + color=0xE02B2B, + ) + await context.send(embed=embed) + elif isinstance(error, commands.NotOwner): + embed = discord.Embed( + description="You are not the owner of the bot!", color=0xE02B2B + ) + await context.send(embed=embed) + if context.guild: + self.logger.warning( + f"{context.author} (ID: {context.author.id}) tried to execute an owner only command in the guild {context.guild.name} (ID: {context.guild.id}), but the user is not an owner of the bot." + ) + else: + self.logger.warning( + f"{context.author} (ID: {context.author.id}) tried to execute an owner only command in the bot's DMs, but the user is not an owner of the bot." + ) + elif isinstance(error, commands.MissingPermissions): + embed = discord.Embed( + description="You are missing the permission(s) `" + + ", ".join(error.missing_permissions) + + "` to execute this command!", + color=0xE02B2B, + ) + await context.send(embed=embed) + elif isinstance(error, commands.BotMissingPermissions): + embed = discord.Embed( + description="I am missing the permission(s) `" + + ", ".join(error.missing_permissions) + + "` to fully perform this command!", + color=0xE02B2B, + ) + await context.send(embed=embed) + elif isinstance(error, commands.MissingRequiredArgument): + embed = discord.Embed( + title="Error!", + # We need to capitalize because the command arguments have no capital letter in the code and they are the first word in the error message. + description=str(error).capitalize(), + color=0xE02B2B, + ) + await context.send(embed=embed) + else: + raise error + + +bot = DiscordBot() +bot.run(os.getenv("TOKEN")) diff --git a/cogs/fun.py b/cogs/fun.py new file mode 100644 index 0000000..c65c32a --- /dev/null +++ b/cogs/fun.py @@ -0,0 +1,163 @@ +""" +Copyright ยฉ Krypton 2019-Present - https://github.com/kkrypt0nn (https://krypton.ninja) +Description: +๐Ÿ A simple template to start to code your own and personalized Discord bot in Python + +Version: 6.4.0 +""" + +import random + +import aiohttp +import discord +from discord.ext import commands +from discord.ext.commands import Context + + +class Choice(discord.ui.View): + def __init__(self) -> None: + super().__init__() + self.value = None + + @discord.ui.button(label="Heads", style=discord.ButtonStyle.blurple) + async def confirm( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + self.value = "heads" + self.stop() + + @discord.ui.button(label="Tails", style=discord.ButtonStyle.blurple) + async def cancel( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + self.value = "tails" + self.stop() + + +class RockPaperScissors(discord.ui.Select): + def __init__(self) -> None: + options = [ + discord.SelectOption( + label="Scissors", description="You choose scissors.", emoji="โœ‚" + ), + discord.SelectOption( + label="Rock", description="You choose rock.", emoji="๐Ÿชจ" + ), + discord.SelectOption( + label="Paper", description="You choose paper.", emoji="๐Ÿงป" + ), + ] + super().__init__( + placeholder="Choose...", + min_values=1, + max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction) -> None: + choices = { + "rock": 0, + "paper": 1, + "scissors": 2, + } + user_choice = self.values[0].lower() + user_choice_index = choices[user_choice] + + bot_choice = random.choice(list(choices.keys())) + bot_choice_index = choices[bot_choice] + + result_embed = discord.Embed(color=0xBEBEFE) + result_embed.set_author( + name=interaction.user.name, icon_url=interaction.user.display_avatar.url + ) + + winner = (3 + user_choice_index - bot_choice_index) % 3 + if winner == 0: + result_embed.description = f"**That's a draw!**\nYou've chosen {user_choice} and I've chosen {bot_choice}." + result_embed.colour = 0xF59E42 + elif winner == 1: + result_embed.description = f"**You won!**\nYou've chosen {user_choice} and I've chosen {bot_choice}." + result_embed.colour = 0x57F287 + else: + result_embed.description = f"**You lost!**\nYou've chosen {user_choice} and I've chosen {bot_choice}." + result_embed.colour = 0xE02B2B + + await interaction.response.edit_message( + embed=result_embed, content=None, view=None + ) + + +class RockPaperScissorsView(discord.ui.View): + def __init__(self) -> None: + super().__init__() + self.add_item(RockPaperScissors()) + + +class Fun(commands.Cog, name="fun"): + def __init__(self, bot) -> None: + self.bot = bot + + @commands.hybrid_command(name="randomfact", description="Get a random fact.") + async def randomfact(self, context: Context) -> None: + """ + Get a random fact. + + :param context: The hybrid command context. + """ + # This will prevent your bot from stopping everything when doing a web request - see: https://discordpy.readthedocs.io/en/stable/faq.html#how-do-i-make-a-web-request + async with aiohttp.ClientSession() as session: + async with session.get( + "https://uselessfacts.jsph.pl/random.json?language=en" + ) as request: + if request.status == 200: + data = await request.json() + embed = discord.Embed(description=data["text"], color=0xD75BF4) + else: + embed = discord.Embed( + title="Error!", + description="There is something wrong with the API, please try again later", + color=0xE02B2B, + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="coinflip", description="Make a coin flip, but give your bet before." + ) + async def coinflip(self, context: Context) -> None: + """ + Make a coin flip, but give your bet before. + + :param context: The hybrid command context. + """ + buttons = Choice() + embed = discord.Embed(description="What is your bet?", color=0xBEBEFE) + message = await context.send(embed=embed, view=buttons) + await buttons.wait() # We wait for the user to click a button. + result = random.choice(["heads", "tails"]) + if buttons.value == result: + embed = discord.Embed( + description=f"Correct! You guessed `{buttons.value}` and I flipped the coin to `{result}`.", + color=0xBEBEFE, + ) + else: + embed = discord.Embed( + description=f"Woops! You guessed `{buttons.value}` and I flipped the coin to `{result}`, better luck next time!", + color=0xE02B2B, + ) + await message.edit(embed=embed, view=None, content=None) + + @commands.hybrid_command( + name="rps", description="Play the rock paper scissors game against the bot." + ) + async def rock_paper_scissors(self, context: Context) -> None: + """ + Play the rock paper scissors game against the bot. + + :param context: The hybrid command context. + """ + view = RockPaperScissorsView() + await context.send("Please make your choice", view=view) + + +async def setup(bot) -> None: + await bot.add_cog(Fun(bot)) diff --git a/cogs/general.py b/cogs/general.py new file mode 100644 index 0000000..ec418b8 --- /dev/null +++ b/cogs/general.py @@ -0,0 +1,327 @@ +""" +Copyright ยฉ Krypton 2019-Present - https://github.com/kkrypt0nn (https://krypton.ninja) +Description: +๐Ÿ A simple template to start to code your own and personalized Discord bot in Python + +Version: 6.4.0 +""" + +import platform +import random + +import aiohttp +import discord +from discord import app_commands +from discord.ext import commands +from discord.ext.commands import Context + + +class FeedbackForm(discord.ui.Modal, title="Feeedback"): + feedback = discord.ui.TextInput( + label="What do you think about this bot?", + style=discord.TextStyle.long, + placeholder="Type your answer here...", + required=True, + max_length=256, + ) + + async def on_submit(self, interaction: discord.Interaction): + self.interaction = interaction + self.answer = str(self.feedback) + self.stop() + + +class General(commands.Cog, name="general"): + def __init__(self, bot) -> None: + self.bot = bot + self.context_menu_user = app_commands.ContextMenu( + name="Grab ID", callback=self.grab_id + ) + self.bot.tree.add_command(self.context_menu_user) + self.context_menu_message = app_commands.ContextMenu( + name="Remove spoilers", callback=self.remove_spoilers + ) + self.bot.tree.add_command(self.context_menu_message) + + # Message context menu command + async def remove_spoilers( + self, interaction: discord.Interaction, message: discord.Message + ) -> None: + """ + Removes the spoilers from the message. This command requires the MESSAGE_CONTENT intent to work properly. + + :param interaction: The application command interaction. + :param message: The message that is being interacted with. + """ + spoiler_attachment = None + for attachment in message.attachments: + if attachment.is_spoiler(): + spoiler_attachment = attachment + break + embed = discord.Embed( + title="Message without spoilers", + description=message.content.replace("||", ""), + color=0xBEBEFE, + ) + if spoiler_attachment is not None: + embed.set_image(url=attachment.url) + await interaction.response.send_message(embed=embed, ephemeral=True) + + # User context menu command + async def grab_id( + self, interaction: discord.Interaction, user: discord.User + ) -> None: + """ + Grabs the ID of the user. + + :param interaction: The application command interaction. + :param user: The user that is being interacted with. + """ + embed = discord.Embed( + description=f"The ID of {user.mention} is `{user.id}`.", + color=0xBEBEFE, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @commands.hybrid_command( + name="help", description="List all commands the bot has loaded." + ) + async def help(self, context: Context) -> None: + embed = discord.Embed( + title="Help", description="List of available commands:", color=0xBEBEFE + ) + for i in self.bot.cogs: + if i == "owner" and not (await self.bot.is_owner(context.author)): + continue + cog = self.bot.get_cog(i.lower()) + commands = cog.get_commands() + data = [] + for command in commands: + description = command.description.partition("\n")[0] + data.append(f"{command.name} - {description}") + help_text = "\n".join(data) + embed.add_field( + name=i.capitalize(), value=f"```{help_text}```", inline=False + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="botinfo", + description="Get some useful (or not) information about the bot.", + ) + async def botinfo(self, context: Context) -> None: + """ + Get some useful (or not) information about the bot. + + :param context: The hybrid command context. + """ + embed = discord.Embed( + description="Used [Krypton's](https://krypton.ninja) template", + color=0xBEBEFE, + ) + embed.set_author(name="Bot Information") + embed.add_field(name="Owner:", value="Krypton#7331", inline=True) + embed.add_field( + name="Python Version:", value=f"{platform.python_version()}", inline=True + ) + embed.add_field( + name="Prefix:", + value=f"/ (Slash Commands) or {self.bot.bot_prefix} for normal commands", + inline=False, + ) + embed.set_footer(text=f"Requested by {context.author}") + await context.send(embed=embed) + + @commands.hybrid_command( + name="serverinfo", + description="Get some useful (or not) information about the server.", + ) + async def serverinfo(self, context: Context) -> None: + """ + Get some useful (or not) information about the server. + + :param context: The hybrid command context. + """ + roles = [role.name for role in context.guild.roles] + num_roles = len(roles) + if num_roles > 50: + roles = roles[:50] + roles.append(f">>>> Displaying [50/{num_roles}] Roles") + roles = ", ".join(roles) + + embed = discord.Embed( + title="**Server Name:**", description=f"{context.guild}", color=0xBEBEFE + ) + if context.guild.icon is not None: + embed.set_thumbnail(url=context.guild.icon.url) + embed.add_field(name="Server ID", value=context.guild.id) + embed.add_field(name="Member Count", value=context.guild.member_count) + embed.add_field( + name="Text/Voice Channels", value=f"{len(context.guild.channels)}" + ) + embed.add_field(name=f"Roles ({len(context.guild.roles)})", value=roles) + embed.set_footer(text=f"Created at: {context.guild.created_at}") + await context.send(embed=embed) + + @commands.hybrid_command( + name="ping", + description="Check if the bot is alive.", + ) + async def ping(self, context: Context) -> None: + """ + Check if the bot is alive. + + :param context: The hybrid command context. + """ + embed = discord.Embed( + title="๐Ÿ“ Pong!", + description=f"The bot latency is {round(self.bot.latency * 1000)}ms.", + color=0xBEBEFE, + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="invite", + description="Get the invite link of the bot to be able to invite it.", + ) + async def invite(self, context: Context) -> None: + """ + Get the invite link of the bot to be able to invite it. + + :param context: The hybrid command context. + """ + embed = discord.Embed( + description=f"Invite me by clicking [here]({self.bot.invite_link}).", + color=0xD75BF4, + ) + try: + await context.author.send(embed=embed) + await context.send("I sent you a private message!") + except discord.Forbidden: + await context.send(embed=embed) + + @commands.hybrid_command( + name="server", + description="Get the invite link of the discord server of the bot for some support.", + ) + async def server(self, context: Context) -> None: + """ + Get the invite link of the discord server of the bot for some support. + + :param context: The hybrid command context. + """ + embed = discord.Embed( + description=f"Join the support server for the bot by clicking [here](https://discord.gg/mTBrXyWxAF).", + color=0xD75BF4, + ) + try: + await context.author.send(embed=embed) + await context.send("I sent you a private message!") + except discord.Forbidden: + await context.send(embed=embed) + + @commands.hybrid_command( + name="8ball", + description="Ask any question to the bot.", + ) + @app_commands.describe(question="The question you want to ask.") + async def eight_ball(self, context: Context, *, question: str) -> None: + """ + Ask any question to the bot. + + :param context: The hybrid command context. + :param question: The question that should be asked by the user. + """ + answers = [ + "It is certain.", + "It is decidedly so.", + "You may rely on it.", + "Without a doubt.", + "Yes - definitely.", + "As I see, yes.", + "Most likely.", + "Outlook good.", + "Yes.", + "Signs point to yes.", + "Reply hazy, try again.", + "Ask again later.", + "Better not tell you now.", + "Cannot predict now.", + "Concentrate and ask again later.", + "Don't count on it.", + "My reply is no.", + "My sources say no.", + "Outlook not so good.", + "Very doubtful.", + ] + embed = discord.Embed( + title="**My Answer:**", + description=f"{random.choice(answers)}", + color=0xBEBEFE, + ) + embed.set_footer(text=f"The question was: {question}") + await context.send(embed=embed) + + @commands.hybrid_command( + name="bitcoin", + description="Get the current price of bitcoin.", + ) + async def bitcoin(self, context: Context) -> None: + """ + Get the current price of bitcoin. + + :param context: The hybrid command context. + """ + # This will prevent your bot from stopping everything when doing a web request - see: https://discordpy.readthedocs.io/en/stable/faq.html#how-do-i-make-a-web-request + async with aiohttp.ClientSession() as session: + async with session.get( + "https://api.coindesk.com/v1/bpi/currentprice/BTC.json" + ) as request: + if request.status == 200: + data = await request.json() + embed = discord.Embed( + title="Bitcoin price", + description=f"The current price is {data['bpi']['USD']['rate']} :dollar:", + color=0xBEBEFE, + ) + else: + embed = discord.Embed( + title="Error!", + description="There is something wrong with the API, please try again later", + color=0xE02B2B, + ) + await context.send(embed=embed) + + @app_commands.command( + name="feedback", description="Submit a feedback for the owners of the bot" + ) + async def feedback(self, interaction: discord.Interaction) -> None: + """ + Submit a feedback for the owners of the bot. + + :param context: The hybrid command context. + """ + feedback_form = FeedbackForm() + await interaction.response.send_modal(feedback_form) + + await feedback_form.wait() + interaction = feedback_form.interaction + await interaction.response.send_message( + embed=discord.Embed( + description="Thank you for your feedback, the owners have been notified about it.", + color=0xBEBEFE, + ) + ) + + app_owner = (await self.bot.application_info()).owner + await app_owner.send( + embed=discord.Embed( + title="New Feedback", + description=f"{interaction.user} (<@{interaction.user.id}>) has submitted a new feedback:\n```\n{feedback_form.answer}\n```", + color=0xBEBEFE, + ) + ) + + +async def setup(bot) -> None: + await bot.add_cog(General(bot)) diff --git a/cogs/moderation.py b/cogs/moderation.py new file mode 100644 index 0000000..510f4aa --- /dev/null +++ b/cogs/moderation.py @@ -0,0 +1,375 @@ +""" +Copyright ยฉ Krypton 2019-Present - https://github.com/kkrypt0nn (https://krypton.ninja) +Description: +๐Ÿ A simple template to start to code your own and personalized Discord bot in Python + +Version: 6.4.0 +""" + +import os +from datetime import datetime + +import discord +from discord import app_commands +from discord.ext import commands +from discord.ext.commands import Context + + +class Moderation(commands.Cog, name="moderation"): + def __init__(self, bot) -> None: + self.bot = bot + + @commands.hybrid_command( + name="kick", + description="Kick a user out of the server.", + ) + @commands.has_permissions(kick_members=True) + @commands.bot_has_permissions(kick_members=True) + @app_commands.describe( + user="The user that should be kicked.", + reason="The reason why the user should be kicked.", + ) + async def kick( + self, context: Context, user: discord.User, *, reason: str = "Not specified" + ) -> None: + """ + Kick a user out of the server. + + :param context: The hybrid command context. + :param user: The user that should be kicked from the server. + :param reason: The reason for the kick. Default is "Not specified". + """ + member = context.guild.get_member(user.id) or await context.guild.fetch_member( + user.id + ) + if member.guild_permissions.administrator: + embed = discord.Embed( + description="User has administrator permissions.", color=0xE02B2B + ) + await context.send(embed=embed) + else: + try: + embed = discord.Embed( + description=f"**{member}** was kicked by **{context.author}**!", + color=0xBEBEFE, + ) + embed.add_field(name="Reason:", value=reason) + await context.send(embed=embed) + try: + await member.send( + f"You were kicked by **{context.author}** from **{context.guild.name}**!\nReason: {reason}" + ) + except: + # Couldn't send a message in the private messages of the user + pass + await member.kick(reason=reason) + except: + embed = discord.Embed( + description="An error occurred while trying to kick the user. Make sure my role is above the role of the user you want to kick.", + color=0xE02B2B, + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="nick", + description="Change the nickname of a user on a server.", + ) + @commands.has_permissions(manage_nicknames=True) + @commands.bot_has_permissions(manage_nicknames=True) + @app_commands.describe( + user="The user that should have a new nickname.", + nickname="The new nickname that should be set.", + ) + async def nick( + self, context: Context, user: discord.User, *, nickname: str = None + ) -> None: + """ + Change the nickname of a user on a server. + + :param context: The hybrid command context. + :param user: The user that should have its nickname changed. + :param nickname: The new nickname of the user. Default is None, which will reset the nickname. + """ + member = context.guild.get_member(user.id) or await context.guild.fetch_member( + user.id + ) + try: + await member.edit(nick=nickname) + embed = discord.Embed( + description=f"**{member}'s** new nickname is **{nickname}**!", + color=0xBEBEFE, + ) + await context.send(embed=embed) + except: + embed = discord.Embed( + description="An error occurred while trying to change the nickname of the user. Make sure my role is above the role of the user you want to change the nickname.", + color=0xE02B2B, + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="ban", + description="Bans a user from the server.", + ) + @commands.has_permissions(ban_members=True) + @commands.bot_has_permissions(ban_members=True) + @app_commands.describe( + user="The user that should be banned.", + reason="The reason why the user should be banned.", + ) + async def ban( + self, context: Context, user: discord.User, *, reason: str = "Not specified" + ) -> None: + """ + Bans a user from the server. + + :param context: The hybrid command context. + :param user: The user that should be banned from the server. + :param reason: The reason for the ban. Default is "Not specified". + """ + member = context.guild.get_member(user.id) or await context.guild.fetch_member( + user.id + ) + try: + if member.guild_permissions.administrator: + embed = discord.Embed( + description="User has administrator permissions.", color=0xE02B2B + ) + await context.send(embed=embed) + else: + embed = discord.Embed( + description=f"**{member}** was banned by **{context.author}**!", + color=0xBEBEFE, + ) + embed.add_field(name="Reason:", value=reason) + await context.send(embed=embed) + try: + await member.send( + f"You were banned by **{context.author}** from **{context.guild.name}**!\nReason: {reason}" + ) + except: + # Couldn't send a message in the private messages of the user + pass + await member.ban(reason=reason) + except: + embed = discord.Embed( + title="Error!", + description="An error occurred while trying to ban the user. Make sure my role is above the role of the user you want to ban.", + color=0xE02B2B, + ) + await context.send(embed=embed) + + @commands.hybrid_group( + name="warning", + description="Manage warnings of a user on a server.", + ) + @commands.has_permissions(manage_messages=True) + async def warning(self, context: Context) -> None: + """ + Manage warnings of a user on a server. + + :param context: The hybrid command context. + """ + if context.invoked_subcommand is None: + embed = discord.Embed( + description="Please specify a subcommand.\n\n**Subcommands:**\n`add` - Add a warning to a user.\n`remove` - Remove a warning from a user.\n`list` - List all warnings of a user.", + color=0xE02B2B, + ) + await context.send(embed=embed) + + @warning.command( + name="add", + description="Adds a warning to a user in the server.", + ) + @commands.has_permissions(manage_messages=True) + @app_commands.describe( + user="The user that should be warned.", + reason="The reason why the user should be warned.", + ) + async def warning_add( + self, context: Context, user: discord.User, *, reason: str = "Not specified" + ) -> None: + """ + Warns a user in his private messages. + + :param context: The hybrid command context. + :param user: The user that should be warned. + :param reason: The reason for the warn. Default is "Not specified". + """ + member = context.guild.get_member(user.id) or await context.guild.fetch_member( + user.id + ) + total = await self.bot.database.add_warn( + user.id, context.guild.id, context.author.id, reason + ) + embed = discord.Embed( + description=f"**{member}** was warned by **{context.author}**!\nTotal warns for this user: {total}", + color=0xBEBEFE, + ) + embed.add_field(name="Reason:", value=reason) + await context.send(embed=embed) + try: + await member.send( + f"You were warned by **{context.author}** in **{context.guild.name}**!\nReason: {reason}" + ) + except: + # Couldn't send a message in the private messages of the user + await context.send( + f"{member.mention}, you were warned by **{context.author}**!\nReason: {reason}" + ) + + @warning.command( + name="remove", + description="Removes a warning from a user in the server.", + ) + @commands.has_permissions(manage_messages=True) + @app_commands.describe( + user="The user that should get their warning removed.", + warn_id="The ID of the warning that should be removed.", + ) + async def warning_remove( + self, context: Context, user: discord.User, warn_id: int + ) -> None: + """ + Warns a user in his private messages. + + :param context: The hybrid command context. + :param user: The user that should get their warning removed. + :param warn_id: The ID of the warning that should be removed. + """ + member = context.guild.get_member(user.id) or await context.guild.fetch_member( + user.id + ) + total = await self.bot.database.remove_warn(warn_id, user.id, context.guild.id) + embed = discord.Embed( + description=f"I've removed the warning **#{warn_id}** from **{member}**!\nTotal warns for this user: {total}", + color=0xBEBEFE, + ) + await context.send(embed=embed) + + @warning.command( + name="list", + description="Shows the warnings of a user in the server.", + ) + @commands.has_guild_permissions(manage_messages=True) + @app_commands.describe(user="The user you want to get the warnings of.") + async def warning_list(self, context: Context, user: discord.User) -> None: + """ + Shows the warnings of a user in the server. + + :param context: The hybrid command context. + :param user: The user you want to get the warnings of. + """ + warnings_list = await self.bot.database.get_warnings(user.id, context.guild.id) + embed = discord.Embed(title=f"Warnings of {user}", color=0xBEBEFE) + description = "" + if len(warnings_list) == 0: + description = "This user has no warnings." + else: + for warning in warnings_list: + description += f"โ€ข Warned by <@{warning[2]}>: **{warning[3]}** () - Warn ID #{warning[5]}\n" + embed.description = description + await context.send(embed=embed) + + @commands.hybrid_command( + name="purge", + description="Delete a number of messages.", + ) + @commands.has_guild_permissions(manage_messages=True) + @commands.bot_has_permissions(manage_messages=True) + @app_commands.describe(amount="The amount of messages that should be deleted.") + async def purge(self, context: Context, amount: int) -> None: + """ + Delete a number of messages. + + :param context: The hybrid command context. + :param amount: The number of messages that should be deleted. + """ + await context.send( + "Deleting messages..." + ) # Bit of a hacky way to make sure the bot responds to the interaction and doens't get a "Unknown Interaction" response + purged_messages = await context.channel.purge(limit=amount + 1) + embed = discord.Embed( + description=f"**{context.author}** cleared **{len(purged_messages)-1}** messages!", + color=0xBEBEFE, + ) + await context.channel.send(embed=embed) + + @commands.hybrid_command( + name="hackban", + description="Bans a user without the user having to be in the server.", + ) + @commands.has_permissions(ban_members=True) + @commands.bot_has_permissions(ban_members=True) + @app_commands.describe( + user_id="The user ID that should be banned.", + reason="The reason why the user should be banned.", + ) + async def hackban( + self, context: Context, user_id: str, *, reason: str = "Not specified" + ) -> None: + """ + Bans a user without the user having to be in the server. + + :param context: The hybrid command context. + :param user_id: The ID of the user that should be banned. + :param reason: The reason for the ban. Default is "Not specified". + """ + try: + await self.bot.http.ban(user_id, context.guild.id, reason=reason) + user = self.bot.get_user(int(user_id)) or await self.bot.fetch_user( + int(user_id) + ) + embed = discord.Embed( + description=f"**{user}** (ID: {user_id}) was banned by **{context.author}**!", + color=0xBEBEFE, + ) + embed.add_field(name="Reason:", value=reason) + await context.send(embed=embed) + except Exception: + embed = discord.Embed( + description="An error occurred while trying to ban the user. Make sure ID is an existing ID that belongs to a user.", + color=0xE02B2B, + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="archive", + description="Archives in a text file the last messages with a chosen limit of messages.", + ) + @commands.has_permissions(manage_messages=True) + @app_commands.describe( + limit="The limit of messages that should be archived.", + ) + async def archive(self, context: Context, limit: int = 10) -> None: + """ + Archives in a text file the last messages with a chosen limit of messages. This command requires the MESSAGE_CONTENT intent to work properly. + + :param limit: The limit of messages that should be archived. Default is 10. + """ + log_file = f"{context.channel.id}.log" + with open(log_file, "w", encoding="UTF-8") as f: + f.write( + f'Archived messages from: #{context.channel} ({context.channel.id}) in the guild "{context.guild}" ({context.guild.id}) at {datetime.now().strftime("%d.%m.%Y %H:%M:%S")}\n' + ) + async for message in context.channel.history( + limit=limit, before=context.message + ): + attachments = [] + for attachment in message.attachments: + attachments.append(attachment.url) + attachments_text = ( + f"[Attached File{'s' if len(attachments) >= 2 else ''}: {', '.join(attachments)}]" + if len(attachments) >= 1 + else "" + ) + f.write( + f"{message.created_at.strftime('%d.%m.%Y %H:%M:%S')} {message.author} {message.id}: {message.clean_content} {attachments_text}\n" + ) + f = discord.File(log_file) + await context.send(file=f) + os.remove(log_file) + + +async def setup(bot) -> None: + await bot.add_cog(Moderation(bot)) diff --git a/cogs/owner.py b/cogs/owner.py new file mode 100644 index 0000000..deaaa73 --- /dev/null +++ b/cogs/owner.py @@ -0,0 +1,220 @@ +""" +Copyright ยฉ Krypton 2019-Present - https://github.com/kkrypt0nn (https://krypton.ninja) +Description: +๐Ÿ A simple template to start to code your own and personalized Discord bot in Python + +Version: 6.4.0 +""" + +import discord +from discord import app_commands +from discord.ext import commands +from discord.ext.commands import Context + + +class Owner(commands.Cog, name="owner"): + def __init__(self, bot) -> None: + self.bot = bot + + @commands.command( + name="sync", + description="Synchonizes the slash commands.", + ) + @app_commands.describe(scope="The scope of the sync. Can be `global` or `guild`") + @commands.is_owner() + async def sync(self, context: Context, scope: str) -> None: + """ + Synchonizes the slash commands. + + :param context: The command context. + :param scope: The scope of the sync. Can be `global` or `guild`. + """ + + if scope == "global": + await context.bot.tree.sync() + embed = discord.Embed( + description="Slash commands have been globally synchronized.", + color=0xBEBEFE, + ) + await context.send(embed=embed) + return + elif scope == "guild": + context.bot.tree.copy_global_to(guild=context.guild) + await context.bot.tree.sync(guild=context.guild) + embed = discord.Embed( + description="Slash commands have been synchronized in this guild.", + color=0xBEBEFE, + ) + await context.send(embed=embed) + return + embed = discord.Embed( + description="The scope must be `global` or `guild`.", color=0xE02B2B + ) + await context.send(embed=embed) + + @commands.command( + name="unsync", + description="Unsynchonizes the slash commands.", + ) + @app_commands.describe( + scope="The scope of the sync. Can be `global`, `current_guild` or `guild`" + ) + @commands.is_owner() + async def unsync(self, context: Context, scope: str) -> None: + """ + Unsynchonizes the slash commands. + + :param context: The command context. + :param scope: The scope of the sync. Can be `global`, `current_guild` or `guild`. + """ + + if scope == "global": + context.bot.tree.clear_commands(guild=None) + await context.bot.tree.sync() + embed = discord.Embed( + description="Slash commands have been globally unsynchronized.", + color=0xBEBEFE, + ) + await context.send(embed=embed) + return + elif scope == "guild": + context.bot.tree.clear_commands(guild=context.guild) + await context.bot.tree.sync(guild=context.guild) + embed = discord.Embed( + description="Slash commands have been unsynchronized in this guild.", + color=0xBEBEFE, + ) + await context.send(embed=embed) + return + embed = discord.Embed( + description="The scope must be `global` or `guild`.", color=0xE02B2B + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="load", + description="Load a cog", + ) + @app_commands.describe(cog="The name of the cog to load") + @commands.is_owner() + async def load(self, context: Context, cog: str) -> None: + """ + The bot will load the given cog. + + :param context: The hybrid command context. + :param cog: The name of the cog to load. + """ + try: + await self.bot.load_extension(f"cogs.{cog}") + except Exception: + embed = discord.Embed( + description=f"Could not load the `{cog}` cog.", color=0xE02B2B + ) + await context.send(embed=embed) + return + embed = discord.Embed( + description=f"Successfully loaded the `{cog}` cog.", color=0xBEBEFE + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="unload", + description="Unloads a cog.", + ) + @app_commands.describe(cog="The name of the cog to unload") + @commands.is_owner() + async def unload(self, context: Context, cog: str) -> None: + """ + The bot will unload the given cog. + + :param context: The hybrid command context. + :param cog: The name of the cog to unload. + """ + try: + await self.bot.unload_extension(f"cogs.{cog}") + except Exception: + embed = discord.Embed( + description=f"Could not unload the `{cog}` cog.", color=0xE02B2B + ) + await context.send(embed=embed) + return + embed = discord.Embed( + description=f"Successfully unloaded the `{cog}` cog.", color=0xBEBEFE + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="reload", + description="Reloads a cog.", + ) + @app_commands.describe(cog="The name of the cog to reload") + @commands.is_owner() + async def reload(self, context: Context, cog: str) -> None: + """ + The bot will reload the given cog. + + :param context: The hybrid command context. + :param cog: The name of the cog to reload. + """ + try: + await self.bot.reload_extension(f"cogs.{cog}") + except Exception: + embed = discord.Embed( + description=f"Could not reload the `{cog}` cog.", color=0xE02B2B + ) + await context.send(embed=embed) + return + embed = discord.Embed( + description=f"Successfully reloaded the `{cog}` cog.", color=0xBEBEFE + ) + await context.send(embed=embed) + + @commands.hybrid_command( + name="shutdown", + description="Make the bot shutdown.", + ) + @commands.is_owner() + async def shutdown(self, context: Context) -> None: + """ + Shuts down the bot. + + :param context: The hybrid command context. + """ + embed = discord.Embed(description="Shutting down. Bye! :wave:", color=0xBEBEFE) + await context.send(embed=embed) + await self.bot.close() + + @commands.hybrid_command( + name="say", + description="The bot will say anything you want.", + ) + @app_commands.describe(message="The message that should be repeated by the bot") + @commands.is_owner() + async def say(self, context: Context, *, message: str) -> None: + """ + The bot will say anything you want. + + :param context: The hybrid command context. + :param message: The message that should be repeated by the bot. + """ + await context.send(message) + + @commands.hybrid_command( + name="embed", + description="The bot will say anything you want, but within embeds.", + ) + @app_commands.describe(message="The message that should be repeated by the bot") + @commands.is_owner() + async def embed(self, context: Context, *, message: str) -> None: + """ + The bot will say anything you want, but using embeds. + + :param context: The hybrid command context. + :param message: The message that should be repeated by the bot. + """ + embed = discord.Embed(description=message, color=0xBEBEFE) + await context.send(embed=embed) + + +async def setup(bot) -> None: + await bot.add_cog(Owner(bot)) diff --git a/cogs/template.py b/cogs/template.py new file mode 100644 index 0000000..a383764 --- /dev/null +++ b/cogs/template.py @@ -0,0 +1,38 @@ +""" +Copyright ยฉ Krypton 2019-Present - https://github.com/kkrypt0nn (https://krypton.ninja) +Description: +๐Ÿ A simple template to start to code your own and personalized Discord bot in Python + +Version: 6.4.0 +""" + +from discord.ext import commands +from discord.ext.commands import Context + + +# Here we name the cog and create a new class for the cog. +class Template(commands.Cog, name="template"): + def __init__(self, bot) -> None: + self.bot = bot + + # Here you can just add your own commands, you'll always need to provide "self" as first parameter. + + @commands.hybrid_command( + name="testcommand", + description="This is a testing command that does nothing.", + ) + async def testcommand(self, context: Context) -> None: + """ + This is a testing command that does nothing. + + :param context: The application command context. + """ + # Do your stuff here + + # Don't forget to remove "pass", I added this just because there's no content in the method. + pass + + +# And then we finally add the cog to the bot so that it can load, unload, reload and use it's content. +async def setup(bot) -> None: + await bot.add_cog(Template(bot)) diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..e770994 --- /dev/null +++ b/database/__init__.py @@ -0,0 +1,96 @@ +""" +Copyright ยฉ Krypton 2019-Present - https://github.com/kkrypt0nn (https://krypton.ninja) +Description: +๐Ÿ A simple template to start to code your own and personalized Discord bot in Python + +Version: 6.4.0 +""" + +import aiosqlite + + +class DatabaseManager: + def __init__(self, *, connection: aiosqlite.Connection) -> None: + self.connection = connection + + async def add_warn( + self, user_id: int, server_id: int, moderator_id: int, reason: str + ) -> int: + """ + This function will add a warn to the database. + + :param user_id: The ID of the user that should be warned. + :param reason: The reason why the user should be warned. + """ + rows = await self.connection.execute( + "SELECT id FROM warns WHERE user_id=? AND server_id=? ORDER BY id DESC LIMIT 1", + ( + user_id, + server_id, + ), + ) + async with rows as cursor: + result = await cursor.fetchone() + warn_id = result[0] + 1 if result is not None else 1 + await self.connection.execute( + "INSERT INTO warns(id, user_id, server_id, moderator_id, reason) VALUES (?, ?, ?, ?, ?)", + ( + warn_id, + user_id, + server_id, + moderator_id, + reason, + ), + ) + await self.connection.commit() + return warn_id + + async def remove_warn(self, warn_id: int, user_id: int, server_id: int) -> int: + """ + This function will remove a warn from the database. + + :param warn_id: The ID of the warn. + :param user_id: The ID of the user that was warned. + :param server_id: The ID of the server where the user has been warned + """ + await self.connection.execute( + "DELETE FROM warns WHERE id=? AND user_id=? AND server_id=?", + ( + warn_id, + user_id, + server_id, + ), + ) + await self.connection.commit() + rows = await self.connection.execute( + "SELECT COUNT(*) FROM warns WHERE user_id=? AND server_id=?", + ( + user_id, + server_id, + ), + ) + async with rows as cursor: + result = await cursor.fetchone() + return result[0] if result is not None else 0 + + async def get_warnings(self, user_id: int, server_id: int) -> list: + """ + This function will get all the warnings of a user. + + :param user_id: The ID of the user that should be checked. + :param server_id: The ID of the server that should be checked. + :return: A list of all the warnings of the user. + """ + rows = await self.connection.execute( + "SELECT user_id, server_id, moderator_id, reason, strftime('%s', created_at), id FROM warns WHERE user_id=? AND server_id=?", + ( + user_id, + server_id, + ), + ) + async with rows as cursor: + result = await cursor.fetchall() + result_list = [] + for row in result: + result_list.append(row) + return result_list diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..52b109e --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `warns` ( + `id` int(11) NOT NULL, + `user_id` varchar(20) NOT NULL, + `server_id` varchar(20) NOT NULL, + `moderator_id` varchar(20) NOT NULL, + `reason` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..222039d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + discord-bot: + build: . + image: python-discord-bot-template + env_file: + - .env + volumes: + - ./database:/bot/database + - ./discord.log:/bot/discord.log + + # Alternatively you can set the environment variables as such: + # /!\ The token shouldn't be written here, as this file is not ignored from Git /!\ + # environment: + # - PREFIX=YOUR_BOT_PREFIX_HERE + # - INVITE_LINK=YOUR_BOT_INVITE_LINK_HERE \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2eda723 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +aiohttp +aiosqlite +discord.py==2.6.3 +python-dotenv \ No newline at end of file