From 3564d2981c090c8177aea61acb9ad72a1600a4d0 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:52:18 -0400 Subject: [PATCH 01/10] Start creating XP system --- techsupport_bot/commands/whois.py | 3 + techsupport_bot/core/auxiliary.py | 4 + techsupport_bot/core/databases.py | 19 ++++ techsupport_bot/functions/xp.py | 158 ++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 techsupport_bot/functions/xp.py diff --git a/techsupport_bot/commands/whois.py b/techsupport_bot/commands/whois.py index 26f0008c..8ca64789 100644 --- a/techsupport_bot/commands/whois.py +++ b/techsupport_bot/commands/whois.py @@ -71,6 +71,9 @@ async def whois_command( except (app_commands.MissingAnyRole, app_commands.AppCommandError): pass + if "xp" in config.enabled_extensions: + embed.add_field(name="XP", value="NOT YET IMPLEMENTED") + if interaction.permissions.kick_members: flags = [] if member.flags.automod_quarantined_username: diff --git a/techsupport_bot/core/auxiliary.py b/techsupport_bot/core/auxiliary.py index c724cbfb..eb09bcaa 100644 --- a/techsupport_bot/core/auxiliary.py +++ b/techsupport_bot/core/auxiliary.py @@ -54,6 +54,7 @@ async def search_channel_for_message( member_to_match: discord.Member = None, content_to_match: str = "", allow_bot: bool = True, + skip_messages: list[int] = [], ) -> discord.Message: """Searches the last 50 messages in a channel based on given conditions @@ -65,6 +66,7 @@ async def search_channel_for_message( content_to_match (str, optional): The content the message must contain. Defaults to None. allow_bot (bool, optional): If you want to allow messages to be authored by a bot. Defaults to True + skip_messages (list[int], optional): Message IDs to be ignored by the search Returns: discord.Message: The message object that meets the given critera. @@ -74,6 +76,8 @@ async def search_channel_for_message( SEARCH_LIMIT = 50 async for message in channel.history(limit=SEARCH_LIMIT): + if message.id in skip_messages: + continue if ( (member_to_match is None or message.author == member_to_match) and (content_to_match == "" or content_to_match in message.content) diff --git a/techsupport_bot/core/databases.py b/techsupport_bot/core/databases.py index 7f5be522..db90914e 100644 --- a/techsupport_bot/core/databases.py +++ b/techsupport_bot/core/databases.py @@ -364,6 +364,24 @@ class Votes(bot.db.Model): blind: bool = bot.db.Column(bot.db.Boolean, default=False) anonymous: bool = bot.db.Column(bot.db.Boolean, default=False) + class XP(bot.db.Model): + """The postgres table for XP + Currently used in xp.py + + Attributes: + pk (int): The primary key for the database + guild_id (str): The ID of the guild that the XP is for + user_id (str): The ID of the user + xp (int): The amount of XP the user has + """ + + __tablename__ = "user_xp" + + pk: int = bot.db.Column(bot.db.Integer, primary_key=True) + guild_id: str = bot.db.Column(bot.db.String) + user_id: str = bot.db.Column(bot.db.String) + xp: int = bot.db.Column(bot.db.Integer) + bot.models.Applications = Applications bot.models.AppBans = ApplicationBans bot.models.BanLog = BanLog @@ -379,3 +397,4 @@ class Votes(bot.db.Model): bot.models.Listener = Listener bot.models.Rule = Rule bot.models.Votes = Votes + bot.models.XP = XP diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py new file mode 100644 index 00000000..f34547d3 --- /dev/null +++ b/techsupport_bot/functions/xp.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import random +from typing import TYPE_CHECKING, Self + +import discord +import expiringdict +import munch +from core import auxiliary, cogs, extensionconfig +from discord.ext import commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Loading the XP plugin into the bot + + Args: + bot (bot.TechSupportBot): The bot object to register the cogs to + """ + config = extensionconfig.ExtensionConfig() + config.add( + key="categories_counted", + datatype="list", + title="List of category IDs to count for XP", + description="List of category IDs to count for XP", + default=[], + ) + + await bot.add_cog(LevelXP(bot=bot, extension_name="xp")) + bot.add_extension_config("xp", config) + + +class LevelXP(cogs.MatchCog): + """Class for the LevelXP to make it to discord.""" + + async def preconfig(self: Self) -> None: + """Sets up the dict""" + self.ineligible = expiringdict.ExpiringDict( + max_len=1000, + max_age_seconds=60, + ) + + async def match( + self: Self, config: munch.Munch, ctx: commands.Context, content: str + ) -> bool: + """A match function to determine if somehting should be reacted to + + Args: + config (munch.Munch): The guild config for the running bot + content (str): The string content of the message + + Returns: + bool: True if there needs to be a reaction, False otherwise + """ + # Ignore all bot messages + if ctx.message.author.bot: + return False + + # Ignore anyone in the ineligible list + if ctx.author.id in self.ineligible: + return False + + # Ignore messages outside of tracked categories + if ctx.channel.category_id not in config.extensions.xp.categories_counted.value: + return False + + prefix = await self.bot.get_prefix(ctx.message) + + # Ignore messages that are bot commands + if ctx.message.clean_content.startswith(prefix): + return False + + # Ignore messages that are factoid calls + if "factoids" in config.enabled_extensions: + factoid_prefix = prefix = config.extensions.factoids.prefix.value + if ctx.message.clean_content.startswith(factoid_prefix): + return False + + last_message_in_channel = await auxiliary.search_channel_for_message( + channel=ctx.channel, + prefix=prefix, + allow_bot=False, + skip_messages=[ctx.message.id], + ) + if last_message_in_channel.author == ctx.author: + return False + + return True + + async def response( + self: Self, config: munch.Munch, ctx: commands.Context, content: str, _: bool + ) -> None: + """The function to generate and add reactions + + Args: + config (munch.Munch): The guild config for the running bot + ctx (commands.Context): The context in which the message was sent in + content (str): The string content of the message + """ + current_XP = await get_current_XP(self.bot, ctx.author, ctx.guild) + new_XP = random.randint(10, 50) + + await update_current_XP(self.bot, ctx.author, ctx.guild, (current_XP + new_XP)) + + await ctx.channel.send( + f"{ctx.author.display_name}: XP. New: {new_XP}, Total: {current_XP+new_XP}" + ) + self.ineligible[ctx.author.id] = True + + +async def get_current_XP( + bot: object, user: discord.Member, guild: discord.Guild +) -> int: + """Calls to the database to get the current XP for a user. Returns 0 if no XP + + Args: + bot (object): The TS bot object to use for the database lookup + user (discord.Member): The member to look for XP for + guild (discord.Guild): The guild to fetch the XP from + + Returns: + int: The current XP for a given user, or 0 if the user has no XP entry + """ + current_XP = ( + await bot.models.XP.query.where(bot.models.XP.user_id == str(user.id)) + .where(bot.models.XP.guild_id == str(guild.id)) + .gino.first() + ) + if not current_XP: + return 0 + + return current_XP.xp + + +async def update_current_XP( + bot: object, user: discord.Member, guild: discord.Guild, xp: int +) -> None: + """Calls to the database to get the current XP for a user. Returns 0 if no XP + + Args: + bot (object): The TS bot object to use for the database lookup + user (discord.Member): The member to look for XP for + guild (discord.Guild): The guild to fetch the XP from + xp (int): The new XP to give the user + + """ + current_XP = ( + await bot.models.XP.query.where(bot.models.XP.user_id == str(user.id)) + .where(bot.models.XP.guild_id == str(guild.id)) + .gino.first() + ) + if not current_XP: + current_XP = bot.models.XP(user_id=str(user.id), guild_id=str(guild.id), xp=xp) + await current_XP.create() + else: + await current_XP.update(xp=xp).apply() From 5b6a4ef79a18a8ba3f0553838920d87fa13e0e55 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:13:24 -0400 Subject: [PATCH 02/10] Add start of level up process --- techsupport_bot/functions/xp.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py index f34547d3..aeb1a19f 100644 --- a/techsupport_bot/functions/xp.py +++ b/techsupport_bot/functions/xp.py @@ -27,6 +27,13 @@ async def setup(bot: bot.TechSupportBot) -> None: description="List of category IDs to count for XP", default=[], ) + config.add( + key="level_roles", + datatype="dict", + title="Dict of levels in XP:Role ID.", + description="Dict of levels in XP:Role ID", + default={}, + ) await bot.add_cog(LevelXP(bot=bot, extension_name="xp")) bot.add_extension_config("xp", config) @@ -109,6 +116,27 @@ async def response( ) self.ineligible[ctx.author.id] = True + async def apply_level_ups( + self: Self, user: discord.Member, old_xp: int, new_xp: int + ) -> None: + old_level = False + new_level = False + + config = self.bot.guild_configs[user.guild.id] + levels = config.extensions.xp.categories_counted.value + if len(levels) == 0: + return + + for level in levels: + if old_xp >= level: + old_level = levels[level] + if new_xp >= level: + new_level = levels[level] + + if old_level != new_level: + # TODO: Handle level up process + return + async def get_current_XP( bot: object, user: discord.Member, guild: discord.Guild From 6711b7bb49b27481d285b9f3e5264ddb483dc95e Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:45:23 -0400 Subject: [PATCH 03/10] Functionally done --- techsupport_bot/functions/xp.py | 44 +++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py index aeb1a19f..f8f0d2a2 100644 --- a/techsupport_bot/functions/xp.py +++ b/techsupport_bot/functions/xp.py @@ -111,6 +111,8 @@ async def response( await update_current_XP(self.bot, ctx.author, ctx.guild, (current_XP + new_XP)) + await self.apply_level_ups(ctx.author, current_XP, (current_XP + new_XP)) + await ctx.channel.send( f"{ctx.author.display_name}: XP. New: {new_XP}, Total: {current_XP+new_XP}" ) @@ -119,22 +121,44 @@ async def response( async def apply_level_ups( self: Self, user: discord.Member, old_xp: int, new_xp: int ) -> None: - old_level = False - new_level = False + old_level = None + new_level = None - config = self.bot.guild_configs[user.guild.id] - levels = config.extensions.xp.categories_counted.value + config = self.bot.guild_configs[str(user.guild.id)] + levels = config.extensions.xp.level_roles.value + print(levels) if len(levels) == 0: return - for level in levels: - if old_xp >= level: - old_level = levels[level] - if new_xp >= level: - new_level = levels[level] + old_level = max( + ((int(xp), role_id) for xp, role_id in levels.items() if old_xp >= int(xp)), + default=(-1, None), + key=lambda t: t[0], + )[1] + + new_level = max( + ((int(xp), role_id) for xp, role_id in levels.items() if new_xp >= int(xp)), + default=(-1, None), + key=lambda t: t[0], + )[1] if old_level != new_level: - # TODO: Handle level up process + guild = user.guild + + if old_level: + old_role = guild.get_role(old_level) + if old_role in user.roles: + await user.remove_roles( + old_role, reason="Level up - replacing old level role" + ) + + if new_level: + new_role = guild.get_role(new_level) + if new_role not in user.roles: + await user.add_roles( + new_role, reason="Level up - new level role applied" + ) + return From 0df5f88c754ba78619a8a13636dcef474b7ce527 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:53:29 -0400 Subject: [PATCH 04/10] Formatting changes --- techsupport_bot/commands/whois.py | 4 +++- techsupport_bot/core/auxiliary.py | 2 +- techsupport_bot/functions/__init__.py | 1 + techsupport_bot/functions/xp.py | 9 +++++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/techsupport_bot/commands/whois.py b/techsupport_bot/commands/whois.py index 8ca64789..701c04ba 100644 --- a/techsupport_bot/commands/whois.py +++ b/techsupport_bot/commands/whois.py @@ -10,6 +10,7 @@ from commands import application, moderator, notes from core import auxiliary, cogs, moderation from discord import app_commands +from functions import xp if TYPE_CHECKING: import bot @@ -72,7 +73,8 @@ async def whois_command( pass if "xp" in config.enabled_extensions: - embed.add_field(name="XP", value="NOT YET IMPLEMENTED") + current_XP = await xp.get_current_XP(self.bot, member, interaction.guild) + embed.add_field(name="XP", value=current_XP) if interaction.permissions.kick_members: flags = [] diff --git a/techsupport_bot/core/auxiliary.py b/techsupport_bot/core/auxiliary.py index eb09bcaa..fe65cfa1 100644 --- a/techsupport_bot/core/auxiliary.py +++ b/techsupport_bot/core/auxiliary.py @@ -54,7 +54,7 @@ async def search_channel_for_message( member_to_match: discord.Member = None, content_to_match: str = "", allow_bot: bool = True, - skip_messages: list[int] = [], + skip_messages: list[int] = None, ) -> discord.Message: """Searches the last 50 messages in a channel based on given conditions diff --git a/techsupport_bot/functions/__init__.py b/techsupport_bot/functions/__init__.py index df256aa0..489fd38f 100644 --- a/techsupport_bot/functions/__init__.py +++ b/techsupport_bot/functions/__init__.py @@ -2,3 +2,4 @@ from .automod import * from .nickname import * +from .xp import * diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py index f8f0d2a2..a468c5f9 100644 --- a/techsupport_bot/functions/xp.py +++ b/techsupport_bot/functions/xp.py @@ -1,3 +1,5 @@ +"""Module for the XP extension for the discord bot.""" + from __future__ import annotations import random @@ -121,6 +123,13 @@ async def response( async def apply_level_ups( self: Self, user: discord.Member, old_xp: int, new_xp: int ) -> None: + """This function will determine if a user leveled up and apply the proper roles + + Args: + user (discord.Member): The user who just gained XP + old_xp (int): The old amount of XP the user had + new_xp (int): The new amount of XP the user has + """ old_level = None new_level = None From 5d2549b72a4c41ff46bc1d470dd81bfd1e46ed63 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:56:45 -0400 Subject: [PATCH 05/10] Formatting the second --- techsupport_bot/functions/xp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py index a468c5f9..76ce2a10 100644 --- a/techsupport_bot/functions/xp.py +++ b/techsupport_bot/functions/xp.py @@ -52,13 +52,13 @@ async def preconfig(self: Self) -> None: ) async def match( - self: Self, config: munch.Munch, ctx: commands.Context, content: str + self: Self, config: munch.Munch, ctx: commands.Context, _: str ) -> bool: """A match function to determine if somehting should be reacted to Args: config (munch.Munch): The guild config for the running bot - content (str): The string content of the message + ctx (commands.Context): The context that the original message was sent in Returns: bool: True if there needs to be a reaction, False otherwise From 640003aa86167ba7648bdda972938dfdf17b6ae5 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:59:42 -0400 Subject: [PATCH 06/10] Fix major bug --- techsupport_bot/core/auxiliary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/techsupport_bot/core/auxiliary.py b/techsupport_bot/core/auxiliary.py index fe65cfa1..e49ed4a0 100644 --- a/techsupport_bot/core/auxiliary.py +++ b/techsupport_bot/core/auxiliary.py @@ -76,7 +76,7 @@ async def search_channel_for_message( SEARCH_LIMIT = 50 async for message in channel.history(limit=SEARCH_LIMIT): - if message.id in skip_messages: + if skip_messages and message.id in skip_messages: continue if ( (member_to_match is None or message.author == member_to_match) From 9418b02bed916e368ac0eafe8068166020705fc3 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 25 Oct 2025 15:32:00 -0400 Subject: [PATCH 07/10] Fix apply level up function to be more robust, have XP system ignore short messages --- techsupport_bot/functions/xp.py | 61 ++++++++++++++++----------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py index 76ce2a10..e5e3963c 100644 --- a/techsupport_bot/functions/xp.py +++ b/techsupport_bot/functions/xp.py @@ -74,6 +74,10 @@ async def match( # Ignore messages outside of tracked categories if ctx.channel.category_id not in config.extensions.xp.categories_counted.value: return False + + # Ignore messages that are too short + if len(ctx.message.clean_content) < 20: + return False prefix = await self.bot.get_prefix(ctx.message) @@ -109,66 +113,59 @@ async def response( content (str): The string content of the message """ current_XP = await get_current_XP(self.bot, ctx.author, ctx.guild) - new_XP = random.randint(10, 50) + new_XP = random.randint(10, 20) await update_current_XP(self.bot, ctx.author, ctx.guild, (current_XP + new_XP)) - await self.apply_level_ups(ctx.author, current_XP, (current_XP + new_XP)) + await self.apply_level_ups(ctx.author, (current_XP + new_XP)) await ctx.channel.send( f"{ctx.author.display_name}: XP. New: {new_XP}, Total: {current_XP+new_XP}" ) self.ineligible[ctx.author.id] = True - async def apply_level_ups( - self: Self, user: discord.Member, old_xp: int, new_xp: int - ) -> None: + async def apply_level_ups(self: Self, user: discord.Member, new_xp: int) -> None: """This function will determine if a user leveled up and apply the proper roles Args: user (discord.Member): The user who just gained XP - old_xp (int): The old amount of XP the user had new_xp (int): The new amount of XP the user has """ - old_level = None - new_level = None - config = self.bot.guild_configs[str(user.guild.id)] levels = config.extensions.xp.level_roles.value - print(levels) + if len(levels) == 0: return - old_level = max( - ((int(xp), role_id) for xp, role_id in levels.items() if old_xp >= int(xp)), - default=(-1, None), - key=lambda t: t[0], - )[1] + configured_levels = [ + (int(xp_threshold), int(role_id)) + for xp_threshold, role_id in levels.items() + ] + configured_role_ids = {role_id for _, role_id in configured_levels} - new_level = max( - ((int(xp), role_id) for xp, role_id in levels.items() if new_xp >= int(xp)), + # Determine the role id that corresponds to the new XP (target role) + target_role_id = max( + ((xp, role_id) for xp, role_id in configured_levels if new_xp >= xp), default=(-1, None), key=lambda t: t[0], )[1] - if old_level != new_level: - guild = user.guild + # A list of roles IDs related to the level system that the user currently has. + user_level_roles_ids = [ + role.id for role in user.roles if role.id in configured_role_ids + ] - if old_level: - old_role = guild.get_role(old_level) - if old_role in user.roles: - await user.remove_roles( - old_role, reason="Level up - replacing old level role" - ) + # If the user has only the correct role, do nothing. + if user_level_roles_ids == [target_role_id]: + return - if new_level: - new_role = guild.get_role(new_level) - if new_role not in user.roles: - await user.add_roles( - new_role, reason="Level up - new level role applied" - ) + # Otherwise, remove all the roles from user_level_roles and then apply target_role_id + for role_id in user_level_roles_ids: + role_object = await user.guild.fetch_role(role_id) + await user.remove_roles(role_object, reason="Level up") - return + target_role_object = await user.guild.fetch_role(target_role_id) + await user.add_roles(target_role_object, reason="Level up") async def get_current_XP( From 771fff5c44d6af25bf2c351fcda41ae824e6488b Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 25 Oct 2025 15:32:45 -0400 Subject: [PATCH 08/10] Delete testing print statement --- techsupport_bot/functions/xp.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py index e5e3963c..7fe909e0 100644 --- a/techsupport_bot/functions/xp.py +++ b/techsupport_bot/functions/xp.py @@ -119,9 +119,6 @@ async def response( await self.apply_level_ups(ctx.author, (current_XP + new_XP)) - await ctx.channel.send( - f"{ctx.author.display_name}: XP. New: {new_XP}, Total: {current_XP+new_XP}" - ) self.ineligible[ctx.author.id] = True async def apply_level_ups(self: Self, user: discord.Member, new_xp: int) -> None: From a764548a9ef05a206155036e8d0df14c8205845a Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 25 Oct 2025 15:34:09 -0400 Subject: [PATCH 09/10] Formatting --- techsupport_bot/functions/xp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py index 7fe909e0..f9bcee02 100644 --- a/techsupport_bot/functions/xp.py +++ b/techsupport_bot/functions/xp.py @@ -74,7 +74,7 @@ async def match( # Ignore messages outside of tracked categories if ctx.channel.category_id not in config.extensions.xp.categories_counted.value: return False - + # Ignore messages that are too short if len(ctx.message.clean_content) < 20: return False From 04f6f20528356ad62d55233d034075b01e6e6dee Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Sat, 25 Oct 2025 15:37:28 -0400 Subject: [PATCH 10/10] Update docstrings --- techsupport_bot/functions/xp.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/techsupport_bot/functions/xp.py b/techsupport_bot/functions/xp.py index f9bcee02..5971bfa8 100644 --- a/techsupport_bot/functions/xp.py +++ b/techsupport_bot/functions/xp.py @@ -54,14 +54,14 @@ async def preconfig(self: Self) -> None: async def match( self: Self, config: munch.Munch, ctx: commands.Context, _: str ) -> bool: - """A match function to determine if somehting should be reacted to + """Checks a given message to determine if XP should be applied Args: config (munch.Munch): The guild config for the running bot ctx (commands.Context): The context that the original message was sent in Returns: - bool: True if there needs to be a reaction, False otherwise + bool: True if XP should be granted, False if it shouldn't be. """ # Ignore all bot messages if ctx.message.author.bot: @@ -105,7 +105,8 @@ async def match( async def response( self: Self, config: munch.Munch, ctx: commands.Context, content: str, _: bool ) -> None: - """The function to generate and add reactions + """Updates XP for the given user. + Message has already been validated when you reach this function. Args: config (munch.Munch): The guild config for the running bot