diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 3f39296..57321d3 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: ["3.13"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -18,6 +18,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r ./requirements.txt + pip install pylint cp base_configs.py configs.py - name: Analysing the code with pylint run: | diff --git a/.pylintrc b/.pylintrc index 3890a91..0746fdb 100644 --- a/.pylintrc +++ b/.pylintrc @@ -4,7 +4,7 @@ max-line-length=120 min-public-methods=0 [MESSAGES CONTROL] -# Disable the message "C0114: Missing module docstring" +# Disabled the message "C0114: Missing module docstring" # Disabled the message "W0511: fixme" # Disabled the message "R0913: Too many arguments" # Disabled the message "W0212: Access to a protected member _ of a client class" diff --git a/.vscode/settings.json b/.vscode/settings.json index 4612ab7..2b23e74 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,12 @@ { - "python.analysis.importFormat": "absolute", "editor.defaultFormatter": "ms-python.black-formatter", + "files.trimTrailingWhitespace": true, "editor.formatOnSave": true, - "black-formatter.args": ["--line-length", "120", "--target-version", "py311"] + + "isort.args": ["--profile", "black"], + "python.analysis.importFormat": "absolute", + "editor.codeActionsOnSave": { + "source.organizeImports": "always", + "source.fixAll": "always" + } } diff --git a/README.md b/README.md index a3ef573..27f5b94 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ A discord bot for managing your server ### Requirements -- Python 3.10+ -- MongoDB 5.0+ +- Python 3.13+ +- MongoDB 8.0+ - Dependencies (`pip3 install -r requirements.txt`) ### Setting up the bot @@ -46,4 +46,4 @@ mongo> db.createUser({user: "DB_USER", pwd: "DB_PWD", roles: [{role: "root", db: ### Using python -> `python3.10 run.py` +> `python3 run.py` diff --git a/bot/botcommands/__init__.py b/bot/botcommands/__init__.py index 4abeb39..c2e1a35 100644 --- a/bot/botcommands/__init__.py +++ b/bot/botcommands/__init__.py @@ -3,3 +3,4 @@ from .bot_configs import BotConfigsCog from .member import MemberCog from .moderation import ModerationCog +from .reaction_role import ReactionRoleCog diff --git a/bot/botcommands/member.py b/bot/botcommands/member.py index be62bb6..9c1a430 100644 --- a/bot/botcommands/member.py +++ b/bot/botcommands/member.py @@ -8,7 +8,7 @@ from bot import tickets, util, welcome from bot.botcommands.utils.validators import has_at_least_role from bot.db.models.user import AdeptMember -from bot.db.services.user_service import UserService +from bot.db.services import ReactionRoleService, UserService from bot.interactions import TicketOpeningInteraction from bot.interactions import ticket as ticket_interactions from bot.util import AdeptBotException @@ -19,6 +19,7 @@ class MemberCog(commands.Cog): def __init__(self) -> None: self.user_service = UserService() + self.reaction_role_service = ReactionRoleService() @commands.command() async def ticket(self, ctx: Context, ticket: tickets.TicketConverter): diff --git a/bot/botcommands/moderation.py b/bot/botcommands/moderation.py index 0a340d4..18e7dbf 100644 --- a/bot/botcommands/moderation.py +++ b/bot/botcommands/moderation.py @@ -121,8 +121,8 @@ def __init__( target: discord.User | discord.Member, author: discord.Member, reason: str, + *, parsed_time: ParsedTime = None, - /, ): self.strike = strike self.target = target @@ -219,7 +219,9 @@ async def mute( except (discord.errors.HTTPException, discord.errors.Forbidden): util.logger.warning("Failed to notify mute") - mute_embed = await ModerationEmbedRequest(Strike.MUTE, member, ctx.author, reason, length).moderation_embed + mute_embed = await ModerationEmbedRequest( + Strike.MUTE, member, ctx.author, reason, parsed_time=length + ).moderation_embed await util.mute(member, reason) await util.say(configs.LOGS_CHANNEL, embed=mute_embed) await ctx.message.add_reaction("\u2705") diff --git a/bot/botcommands/reaction_role.py b/bot/botcommands/reaction_role.py new file mode 100644 index 0000000..ccdc91a --- /dev/null +++ b/bot/botcommands/reaction_role.py @@ -0,0 +1,79 @@ +"""This module contains the commands related to the members of the server.""" + +import discord +from discord.ext import commands +from discord.ext.commands.context import Context + +import configs +from bot.botcommands.utils.validators import has_at_least_role +from bot.db.services.reaction_role_service import ReactionRoleService +from bot.util import AdeptBotException + + +class ReactionRoleCog(commands.Cog): + """This class contains the commands related to the members of the server.""" + + def __init__(self, bot: discord.Client) -> None: + self.bot = bot + self.reaction_role_service = ReactionRoleService() + + @commands.command() + @has_at_least_role(configs.ADMIN_ROLE) + async def addreactionrole(self, ctx: Context, message_id: int, emoji: str, role: discord.Role): + """ + Cette commande permet d'ajouter une réaction à un message et de la lier à un rôle. + + Utilisation: + !addreactionrole + """ + message = await ctx.fetch_message(message_id) + + if not message or not role: + raise AdeptBotException("Message ou rôle invalide!") + + await message.add_reaction(emoji) + await self.reaction_role_service.add_reaction_role(message_id, emoji, role.id) + await ctx.send(f"Réaction {emoji} ajoutée au message {message.jump_url} et liée au rôle {role.name}.") + + @commands.command() + @has_at_least_role(configs.ADMIN_ROLE) + async def removereactionrole(self, ctx: Context, message_id: int, emoji: str): + """ + Cette commande permet de retirer une réaction d'un message et de supprimer le lien avec un rôle. + + Utilisation: + !removereactionrole + """ + message = await ctx.fetch_message(message_id) + + if not message: + raise AdeptBotException("Message invalide!") + + await message.clear_reaction(emoji) + await self.reaction_role_service.remove_reaction_role(message_id, emoji) + await ctx.send(f"Réaction {emoji} retirée du message {message.jump_url}.") + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + """This event is called when a reaction is added to a message.""" + if payload.member.bot: + return + + reaction_role = await self.reaction_role_service.get_reaction_role(payload.message_id, str(payload.emoji)) + if reaction_role: + guild = self.bot.get_guild(payload.guild_id) + role = guild.get_role(reaction_role.role_id) + await payload.member.add_roles(role) + + @commands.Cog.listener() + async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): + """This event is called when a reaction is removed from a message.""" + guild = self.bot.get_guild(payload.guild_id) + member = guild.get_member(payload.user_id) + if member.bot: + return + + reaction_role = await self.reaction_role_service.get_reaction_role(payload.message_id, str(payload.emoji)) + if reaction_role: + role = guild.get_role(reaction_role.role_id) + await member.remove_roles(role) diff --git a/bot/db/models/__init__.py b/bot/db/models/__init__.py index b0aaa18..0fb429e 100644 --- a/bot/db/models/__init__.py +++ b/bot/db/models/__init__.py @@ -2,4 +2,5 @@ from .bot_configs import GlobalConfig, SpamConfigs from .entity import Entity +from .reaction_role import ReactionRole from .user import AdeptMember diff --git a/bot/db/models/entity.py b/bot/db/models/entity.py index baba2b1..a8f5d7f 100644 --- a/bot/db/models/entity.py +++ b/bot/db/models/entity.py @@ -1,6 +1,6 @@ """Base entity class for all models""" -from datetime import datetime +from datetime import datetime, timezone class Entity: @@ -19,7 +19,12 @@ class Entity: __slots__ = ("_id", "created_at", "updated_at") - def __init__(self, _id: int, created_at: datetime = datetime.utcnow(), updated_at: datetime = datetime.utcnow()): + def __init__( + self, + _id: int, + created_at: datetime = datetime.now(timezone.utc), + updated_at: datetime = datetime.now(timezone.utc), + ): self._id = _id self.created_at = created_at self.updated_at = updated_at diff --git a/bot/db/models/reaction_role.py b/bot/db/models/reaction_role.py new file mode 100644 index 0000000..3965f8c --- /dev/null +++ b/bot/db/models/reaction_role.py @@ -0,0 +1,37 @@ +"""ReactionRole model for linking reactions to roles.""" + +from bot.db.models.entity import Entity + + +class ReactionRole(Entity): + """ + ReactionRole model for linking reactions to roles. + + Attributes + ---------- + `message_id` : int + The ID of the message. + `emoji` : str + The emoji used for the reaction. + `role_id` : int + The ID of the role to assign. + """ + + __slots__ = ("message_id", "emoji", "role_id") + + def __init__(self, _id: int, message_id: int, emoji: str, role_id: int): + super().__init__(_id) + self.message_id = message_id + self.emoji = emoji + self.role_id = role_id + + def __getstate__(self): + state = super().__getstate__() + state.update( + { + "message_id": self.message_id, + "emoji": self.emoji, + "role_id": self.role_id, + } + ) + return state diff --git a/bot/db/models/user.py b/bot/db/models/user.py index c0f9ec4..0ee54e9 100644 --- a/bot/db/models/user.py +++ b/bot/db/models/user.py @@ -41,7 +41,7 @@ def __init__( name: str, email: str, is_student: bool, - /, + *, is_teacher: bool = False, is_it_student: bool = False, student_id: int = None, diff --git a/bot/db/services/__init__.py b/bot/db/services/__init__.py index 4bfe99a..58e8534 100644 --- a/bot/db/services/__init__.py +++ b/bot/db/services/__init__.py @@ -2,4 +2,5 @@ from .base_service import BaseService from .configs_service import ConfigsService +from .reaction_role_service import ReactionRoleService from .user_service import UserService diff --git a/bot/db/services/reaction_role_service.py b/bot/db/services/reaction_role_service.py new file mode 100644 index 0000000..e8a223b --- /dev/null +++ b/bot/db/services/reaction_role_service.py @@ -0,0 +1,73 @@ +"""Service class for ReactionRole model.""" + +from bot.db.models.reaction_role import ReactionRole +from bot.db.services.base_service import BaseService + + +class ReactionRoleService(BaseService): + """ + Service class for ReactionRole model. + + Methods + ------- + `add_reaction_role` : None + Add a reaction role to the database. + `remove_reaction_role` : None + Remove a reaction role from the database. + `get_reaction_role` : ReactionRole + Get a reaction role from the database. + """ + + @property + def collection_name(self): + return "reaction_roles" + + async def add_reaction_role(self, message_id: int, emoji: str, role_id: int): + """ + Add a reaction role to the database. + + Parameters + ---------- + `message_id` : int + The ID of the message. + `emoji` : str + The emoji used for the reaction. + `role_id` : int + The ID of the role to assign. + """ + reaction_role = ReactionRole(None, message_id, emoji, role_id) + self.insert_one(reaction_role.__getstate__()) + + async def remove_reaction_role(self, message_id: int, emoji: str): + """ + Remove a reaction role from the database. + + Parameters + ---------- + `message_id` : int + The ID of the message. + `emoji` : str + The emoji used for the reaction. + """ + self.delete_one({"message_id": message_id, "emoji": emoji}) + + async def get_reaction_role(self, message_id: int, emoji: str) -> ReactionRole | None: + """ + Get a reaction role from the database. + + Parameters + ---------- + `message_id` : int + The ID of the message. + `emoji` : str + The emoji used for the reaction. + + Returns + ------- + ReactionRole + The reaction role. + """ + result = self.find_one({"message_id": message_id, "emoji": emoji}) + if result: + return ReactionRole(result["_id"], result["message_id"], result["emoji"], result["role_id"]) + return None diff --git a/bot/management/logging.py b/bot/management/logging.py index 1873ff2..af83f2c 100644 --- a/bot/management/logging.py +++ b/bot/management/logging.py @@ -5,11 +5,16 @@ import configs from bot import util +from bot.db.services.reaction_role_service import ReactionRoleService class LoggingCog(commands.Cog): """This class contains the events related to logging.""" + def __init__(self, bot: discord.Client): + self.reaction_role_service = ReactionRoleService() + self.bot = bot + @commands.Cog.listener() async def on_message_edit(self, before: discord.Message, after: discord.Message): """This event is called when a message is edited.""" diff --git a/bot/management/welcome.py b/bot/management/welcome.py index 4093d61..f28793a 100644 --- a/bot/management/welcome.py +++ b/bot/management/welcome.py @@ -4,15 +4,16 @@ from discord.ext import commands import configs -from bot import util, welcome +from bot import welcome from bot.db.services import UserService class WelcomeCog(commands.Cog): """This class contains the events related to welcome.""" - def __init__(self) -> None: + def __init__(self, bot: discord.Client) -> None: self.user_service = UserService() + self.bot = bot @commands.command() @commands.guild_only() @@ -38,7 +39,7 @@ async def on_member_join(self, member: discord.Member): if adept_member: return await welcome.process_welcome_result(member, adept_member) - await util.say(configs.WELCOME_CHANNEL, configs.WELCOME_SERVER.format(name=member.mention)) + await self.bot.say(configs.WELCOME_CHANNEL, configs.WELCOME_SERVER.format(name=member.mention)) result = await welcome.walk_through_welcome(member) if not result: return diff --git a/bot/tasks.py b/bot/tasks.py index 551e001..fcae33f 100644 --- a/bot/tasks.py +++ b/bot/tasks.py @@ -91,7 +91,7 @@ async def process_mutes(): process_mutes.stop() -def load_tasks(): +async def load_tasks(bot: discord.Client): """ Loads all tasks from the database. @@ -102,7 +102,7 @@ def load_tasks(): for task in to_process: # Just to make the code more readable - member = util.get_member(task["guild"], task["member"]) + member = bot.get_guild(task["guild"]).get_member(task["member"]) end_date = datetime.datetime.strptime(task["end_date"], "%Y-%m-%d %H:%M:%S.%f") TASK_LIST.append(Task(member, end_date, task["type"])) diff --git a/bot/util.py b/bot/util.py index 1dbeba7..6900267 100644 --- a/bot/util.py +++ b/bot/util.py @@ -8,6 +8,7 @@ import configs from bot.strikes import Strike +from bot.db.services import ReactionRoleService CLIENT: discord.Client = None @@ -34,32 +35,6 @@ def __init__(self, message: str) -> None: super().__init__(self.message) -def get_member(guild_id: int, member_id: int): - """ - Get a member from a guild. - - Parameters - ---------- - `guild_id` : int - The id of the guild. - `member_id` : int - The id of the member. - """ - return CLIENT.get_guild(guild_id).get_member(member_id) - - -def get_guild(guild_id: int): - """ - Get a guild. - - Parameters - ---------- - `guild_id` : int - The id of the guild. - """ - return CLIENT.get_guild(guild_id) - - def get_case_number(): """Get the next case number.""" raise NotImplementedError() @@ -281,3 +256,35 @@ def load(loaded_client): """ global CLIENT CLIENT = loaded_client + + +async def add_reaction_role(message_id: int, emoji: str, role_id: int): + """ + Add a reaction role. + + Parameters + ---------- + `message_id` : int + The id of the message to add the reaction to. + `emoji` : str + The emoji to add as a reaction. + `role_id` : int + The id of the role to assign when the reaction is added. + """ + reaction_role_service = ReactionRoleService() + await reaction_role_service.add_reaction_role(message_id, emoji, role_id) + + +async def remove_reaction_role(message_id: int, emoji: str): + """ + Remove a reaction role. + + Parameters + ---------- + `message_id` : int + The id of the message to remove the reaction from. + `emoji` : str + The emoji to remove as a reaction. + """ + reaction_role_service = ReactionRoleService() + await reaction_role_service.remove_reaction_role(message_id, emoji) diff --git a/dockerfile b/dockerfile index 7f003a8..2822722 100644 --- a/dockerfile +++ b/dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM python:3.13-slim WORKDIR /usr/src/app diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fcd5b83 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.isort] +profile = "black" + +[tool.black] +line-length = 120 +target-version = ["py313"] diff --git a/requirements.txt b/requirements.txt index b2ed48c..79ce488 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,4 @@ discord.py[speed]<3.0.0 jsonpickle pymongo[srv]<5.0.0 - -# Linter -pylint -# Formatter -black +audioop-lts diff --git a/run.py b/run.py index 1f3a112..566ff97 100755 --- a/run.py +++ b/run.py @@ -17,7 +17,7 @@ import configs from bot import tasks, util -from bot.botcommands import BotConfigsCog, MemberCog, ModerationCog +from bot.botcommands import BotConfigsCog, MemberCog, ModerationCog, ReactionRoleCog from bot.interactions import TicketCloseInteraction, TicketOpeningInteraction from bot.interactions.errors import NoReplyException from bot.management import LoggingCog, StrikesCog, WelcomeCog @@ -32,20 +32,21 @@ def __init__(self, prefix: str, intents: discord.Intents): async def on_ready(self): """Called when the bot is ready.""" util.logger.info( - "\nLogged in with account @%s ID:%s \n------------------------------------\n", self.user.name, self.user.id + "\nLogged in with account @%s ID:%s \n------------------------------------", self.user.name, self.user.id ) await self.change_presence(activity=discord.Activity(name="for bad boys!", type=discord.ActivityType.watching)) - tasks.load_tasks() + await tasks.load_tasks(self) async def setup_hook(self) -> None: # Register cogs await self.add_cog(BotConfigsCog()) - await self.add_cog(LoggingCog()) + await self.add_cog(LoggingCog(self)) await self.add_cog(MemberCog()) await self.add_cog(ModerationCog()) await self.add_cog(StrikesCog()) - await self.add_cog(WelcomeCog()) + await self.add_cog(WelcomeCog(self)) + await self.add_cog(ReactionRoleCog(self)) # Register persistent views self.add_view(TicketOpeningInteraction()) @@ -105,9 +106,9 @@ async def on_command_error(self, ctx: commands.Context, exception: commands.erro if isinstance(ctx, CommandNotFound): # We don't care - pass + return - elif isinstance(exception, NoPrivateMessage): + if isinstance(exception, NoPrivateMessage): await ctx.send("Cette commande ne peut pas être utilisée en message privé.") elif isinstance(exception, UserNotFound): @@ -122,7 +123,7 @@ async def on_command_error(self, ctx: commands.Context, exception: commands.erro elif isinstance(exception, BadArgument): await ctx.send(f"Argument invalide: {exception.param.name}") - elif isinstance(exception, NoReplyException): + elif isinstance(exception, (NoReplyException)): # , InsufficientPermissionsError)): await exception.channel.send(exception.message) elif isinstance(exception, discord.Forbidden):