commit 6583e7ccbbbdff86d02d85155ad091706cd15171 Author: neoarz Date: Sun Sep 14 16:09:43 2025 -0400 Initial commit 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