Skip to content

Commit d6e325d

Browse files
authored
Adds a configurable XP/level up system to TS (#1386)
* Start creating XP system * Add start of level up process * Functionally done * Formatting changes * Formatting the second * Fix major bug * Fix apply level up function to be more robust, have XP system ignore short messages * Delete testing print statement * Formatting * Update docstrings * Fix bug with first messages
1 parent a983398 commit d6e325d

5 files changed

Lines changed: 246 additions & 0 deletions

File tree

techsupport_bot/commands/whois.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from commands import application, moderator, notes
1111
from core import auxiliary, cogs, moderation
1212
from discord import app_commands
13+
from functions import xp
1314

1415
if TYPE_CHECKING:
1516
import bot
@@ -75,6 +76,10 @@ async def whois_command(
7576
except (app_commands.MissingAnyRole, app_commands.AppCommandError):
7677
pass
7778

79+
if "xp" in config.enabled_extensions:
80+
current_XP = await xp.get_current_XP(self.bot, member, interaction.guild)
81+
embed.add_field(name="XP", value=current_XP)
82+
7883
if interaction.permissions.kick_members:
7984
flags = []
8085
if member.flags.automod_quarantined_username:

techsupport_bot/core/auxiliary.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ async def search_channel_for_message(
5353
member_to_match: discord.Member = None,
5454
content_to_match: str = "",
5555
allow_bot: bool = True,
56+
skip_messages: list[int] = None,
5657
) -> discord.Message:
5758
"""Searches the last 50 messages in a channel based on given conditions
5859
@@ -64,6 +65,7 @@ async def search_channel_for_message(
6465
content_to_match (str, optional): The content the message must contain. Defaults to None.
6566
allow_bot (bool, optional): If you want to allow messages to
6667
be authored by a bot. Defaults to True
68+
skip_messages (list[int], optional): Message IDs to be ignored by the search
6769
6870
Returns:
6971
discord.Message: The message object that meets the given critera.
@@ -73,6 +75,8 @@ async def search_channel_for_message(
7375
SEARCH_LIMIT = 50
7476

7577
async for message in channel.history(limit=SEARCH_LIMIT):
78+
if skip_messages and message.id in skip_messages:
79+
continue
7680
if (
7781
(member_to_match is None or message.author == member_to_match)
7882
and (content_to_match == "" or content_to_match in message.content)

techsupport_bot/core/databases.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,24 @@ class Votes(bot.db.Model):
364364
blind: bool = bot.db.Column(bot.db.Boolean, default=False)
365365
anonymous: bool = bot.db.Column(bot.db.Boolean, default=False)
366366

367+
class XP(bot.db.Model):
368+
"""The postgres table for XP
369+
Currently used in xp.py
370+
371+
Attributes:
372+
pk (int): The primary key for the database
373+
guild_id (str): The ID of the guild that the XP is for
374+
user_id (str): The ID of the user
375+
xp (int): The amount of XP the user has
376+
"""
377+
378+
__tablename__ = "user_xp"
379+
380+
pk: int = bot.db.Column(bot.db.Integer, primary_key=True)
381+
guild_id: str = bot.db.Column(bot.db.String)
382+
user_id: str = bot.db.Column(bot.db.String)
383+
xp: int = bot.db.Column(bot.db.Integer)
384+
367385
bot.models.Applications = Applications
368386
bot.models.AppBans = ApplicationBans
369387
bot.models.BanLog = BanLog
@@ -379,3 +397,4 @@ class Votes(bot.db.Model):
379397
bot.models.Listener = Listener
380398
bot.models.Rule = Rule
381399
bot.models.Votes = Votes
400+
bot.models.XP = XP

techsupport_bot/functions/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
from .automod import *
44
from .logger import *
55
from .nickname import *
6+
from .xp import *

techsupport_bot/functions/xp.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""Module for the XP extension for the discord bot."""
2+
3+
from __future__ import annotations
4+
5+
import random
6+
from typing import TYPE_CHECKING, Self
7+
8+
import discord
9+
import expiringdict
10+
import munch
11+
from core import auxiliary, cogs, extensionconfig
12+
from discord.ext import commands
13+
14+
if TYPE_CHECKING:
15+
import bot
16+
17+
18+
async def setup(bot: bot.TechSupportBot) -> None:
19+
"""Loading the XP plugin into the bot
20+
21+
Args:
22+
bot (bot.TechSupportBot): The bot object to register the cogs to
23+
"""
24+
config = extensionconfig.ExtensionConfig()
25+
config.add(
26+
key="categories_counted",
27+
datatype="list",
28+
title="List of category IDs to count for XP",
29+
description="List of category IDs to count for XP",
30+
default=[],
31+
)
32+
config.add(
33+
key="level_roles",
34+
datatype="dict",
35+
title="Dict of levels in XP:Role ID.",
36+
description="Dict of levels in XP:Role ID",
37+
default={},
38+
)
39+
40+
await bot.add_cog(LevelXP(bot=bot, extension_name="xp"))
41+
bot.add_extension_config("xp", config)
42+
43+
44+
class LevelXP(cogs.MatchCog):
45+
"""Class for the LevelXP to make it to discord."""
46+
47+
async def preconfig(self: Self) -> None:
48+
"""Sets up the dict"""
49+
self.ineligible = expiringdict.ExpiringDict(
50+
max_len=1000,
51+
max_age_seconds=60,
52+
)
53+
54+
async def match(
55+
self: Self, config: munch.Munch, ctx: commands.Context, _: str
56+
) -> bool:
57+
"""Checks a given message to determine if XP should be applied
58+
59+
Args:
60+
config (munch.Munch): The guild config for the running bot
61+
ctx (commands.Context): The context that the original message was sent in
62+
63+
Returns:
64+
bool: True if XP should be granted, False if it shouldn't be.
65+
"""
66+
# Ignore all bot messages
67+
if ctx.message.author.bot:
68+
return False
69+
70+
# Ignore anyone in the ineligible list
71+
if ctx.author.id in self.ineligible:
72+
return False
73+
74+
# Ignore messages outside of tracked categories
75+
if ctx.channel.category_id not in config.extensions.xp.categories_counted.value:
76+
return False
77+
78+
# Ignore messages that are too short
79+
if len(ctx.message.clean_content) < 20:
80+
return False
81+
82+
prefix = await self.bot.get_prefix(ctx.message)
83+
84+
# Ignore messages that are bot commands
85+
if ctx.message.clean_content.startswith(prefix):
86+
return False
87+
88+
# Ignore messages that are factoid calls
89+
if "factoids" in config.enabled_extensions:
90+
factoid_prefix = prefix = config.extensions.factoids.prefix.value
91+
if ctx.message.clean_content.startswith(factoid_prefix):
92+
return False
93+
94+
last_message_in_channel = await auxiliary.search_channel_for_message(
95+
channel=ctx.channel,
96+
prefix=prefix,
97+
allow_bot=False,
98+
skip_messages=[ctx.message.id],
99+
)
100+
if last_message_in_channel.author == ctx.author:
101+
return False
102+
103+
return True
104+
105+
async def response(
106+
self: Self, config: munch.Munch, ctx: commands.Context, content: str, _: bool
107+
) -> None:
108+
"""Updates XP for the given user.
109+
Message has already been validated when you reach this function.
110+
111+
Args:
112+
config (munch.Munch): The guild config for the running bot
113+
ctx (commands.Context): The context in which the message was sent in
114+
content (str): The string content of the message
115+
"""
116+
current_XP = await get_current_XP(self.bot, ctx.author, ctx.guild)
117+
new_XP = random.randint(10, 20)
118+
119+
await update_current_XP(self.bot, ctx.author, ctx.guild, (current_XP + new_XP))
120+
121+
await self.apply_level_ups(ctx.author, (current_XP + new_XP))
122+
123+
self.ineligible[ctx.author.id] = True
124+
125+
async def apply_level_ups(self: Self, user: discord.Member, new_xp: int) -> None:
126+
"""This function will determine if a user leveled up and apply the proper roles
127+
128+
Args:
129+
user (discord.Member): The user who just gained XP
130+
new_xp (int): The new amount of XP the user has
131+
"""
132+
config = self.bot.guild_configs[str(user.guild.id)]
133+
levels = config.extensions.xp.level_roles.value
134+
135+
if len(levels) == 0:
136+
return
137+
138+
configured_levels = [
139+
(int(xp_threshold), int(role_id))
140+
for xp_threshold, role_id in levels.items()
141+
]
142+
configured_role_ids = {role_id for _, role_id in configured_levels}
143+
144+
# Determine the role id that corresponds to the new XP (target role)
145+
target_role_id = max(
146+
((xp, role_id) for xp, role_id in configured_levels if new_xp >= xp),
147+
default=(-1, None),
148+
key=lambda t: t[0],
149+
)[1]
150+
151+
# A list of roles IDs related to the level system that the user currently has.
152+
user_level_roles_ids = [
153+
role.id for role in user.roles if role.id in configured_role_ids
154+
]
155+
156+
# If the user has only the correct role, do nothing.
157+
if user_level_roles_ids == [target_role_id]:
158+
return
159+
160+
# Otherwise, remove all the roles from user_level_roles and then apply target_role_id
161+
for role_id in user_level_roles_ids:
162+
role_object = await user.guild.fetch_role(role_id)
163+
await user.remove_roles(role_object, reason="Level up")
164+
165+
if not target_role_id:
166+
return
167+
168+
target_role_object = await user.guild.fetch_role(target_role_id)
169+
await user.add_roles(target_role_object, reason="Level up")
170+
171+
172+
async def get_current_XP(
173+
bot: object, user: discord.Member, guild: discord.Guild
174+
) -> int:
175+
"""Calls to the database to get the current XP for a user. Returns 0 if no XP
176+
177+
Args:
178+
bot (object): The TS bot object to use for the database lookup
179+
user (discord.Member): The member to look for XP for
180+
guild (discord.Guild): The guild to fetch the XP from
181+
182+
Returns:
183+
int: The current XP for a given user, or 0 if the user has no XP entry
184+
"""
185+
current_XP = (
186+
await bot.models.XP.query.where(bot.models.XP.user_id == str(user.id))
187+
.where(bot.models.XP.guild_id == str(guild.id))
188+
.gino.first()
189+
)
190+
if not current_XP:
191+
return 0
192+
193+
return current_XP.xp
194+
195+
196+
async def update_current_XP(
197+
bot: object, user: discord.Member, guild: discord.Guild, xp: int
198+
) -> None:
199+
"""Calls to the database to get the current XP for a user. Returns 0 if no XP
200+
201+
Args:
202+
bot (object): The TS bot object to use for the database lookup
203+
user (discord.Member): The member to look for XP for
204+
guild (discord.Guild): The guild to fetch the XP from
205+
xp (int): The new XP to give the user
206+
207+
"""
208+
current_XP = (
209+
await bot.models.XP.query.where(bot.models.XP.user_id == str(user.id))
210+
.where(bot.models.XP.guild_id == str(guild.id))
211+
.gino.first()
212+
)
213+
if not current_XP:
214+
current_XP = bot.models.XP(user_id=str(user.id), guild_id=str(guild.id), xp=xp)
215+
await current_XP.create()
216+
else:
217+
await current_XP.update(xp=xp).apply()

0 commit comments

Comments
 (0)