Skip to content

Commit 019a9bf

Browse files
authored
Merge pull request #175 from python-discord/levels_init
Add Levels functionality
2 parents e6fdd85 + dc5cc8a commit 019a9bf

13 files changed

Lines changed: 490 additions & 0 deletions

bot/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,17 @@ class _Roles(EnvConfig, env_prefix="ROLE_"):
168168
team_dict: int = 1222691368653033652
169169
team_tuple: int = 1222691399246286888
170170

171+
levels_crystal: int = 1488465184803524758
172+
levels_level3: int = 1488465669401088000
173+
levels_s_tier: int = 1488465793082462248
174+
levels_diamond_rank: int = 1488465915560460399
175+
levels_GOAT: int = 1488465529835487232 #noqa: N815
176+
levels_mtfn: int = 1488537117976957030
177+
levels_champion: int = 1488464806855053433
178+
levels_mythical_python_charmer: int = 1488466097412771853
179+
levels_supernova_wonder: int = 1488466164106395718
180+
levels_ascenion_20: int = 1488466329672351876
181+
171182

172183
Roles = _Roles()
173184

bot/exts/levels/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import TYPE_CHECKING
2+
3+
if TYPE_CHECKING:
4+
from bot.bot import SirRobin
5+
6+
7+
async def setup(bot: "SirRobin") -> None:
8+
"""Load the CodeJams cog."""
9+
from bot.exts.levels._cog import Levels
10+
await bot.add_cog(Levels(bot))

bot/exts/levels/_cog.py

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
import random
2+
import re
3+
import tomllib
4+
from dataclasses import dataclass
5+
from pathlib import Path
6+
from typing import Literal
7+
8+
import discord
9+
from async_rediscache import RedisCache
10+
from discord.ext import commands, tasks
11+
from pydis_core.utils.logging import get_logger
12+
13+
from bot import constants
14+
from bot.bot import SirRobin
15+
from bot.utils import members
16+
from bot.utils.decorators import in_whitelist
17+
18+
logger = get_logger(__name__)
19+
20+
ELEVATED_ROLES = (constants.Roles.admins, constants.Roles.moderation_team, constants.Roles.events_lead)
21+
22+
ALLOWED_COMMAND_CHANNELS = (constants.Channels.bot_commands, constants.Channels.sir_lancebot_playground,)
23+
24+
# Channels where the game runs.
25+
ALLOWED_CHANNELS = (
26+
constants.Channels.off_topic_0,
27+
constants.Channels.off_topic_1,
28+
constants.Channels.off_topic_2,
29+
)
30+
31+
LEVEL_ROLES = (
32+
constants.Roles.levels_crystal,
33+
constants.Roles.levels_level3,
34+
constants.Roles.levels_s_tier,
35+
constants.Roles.levels_diamond_rank,
36+
constants.Roles.levels_GOAT,
37+
constants.Roles.levels_mtfn,
38+
constants.Roles.levels_champion,
39+
constants.Roles.levels_mythical_python_charmer,
40+
constants.Roles.levels_supernova_wonder,
41+
constants.Roles.levels_ascenion_20
42+
)
43+
44+
class Levels(commands.Cog):
45+
"""Cog that handles all Level functionality."""
46+
47+
#RedisCache[user_id: int, points: int]
48+
user_points_cache = RedisCache()
49+
50+
#RedisCache[role_id: int, point_threshold: int]
51+
levels_cache = RedisCache()
52+
53+
#RedisCache["value", bool]
54+
running = RedisCache()
55+
56+
def __init__(self, bot: SirRobin):
57+
self.bot = bot
58+
59+
self.rules_all = []
60+
self.rules_pool = []
61+
self.rules_active = []
62+
self.rules_folder_path = Path("./bot/exts/levels/rules/")
63+
self.active_rules_num = 3
64+
65+
self.active_reaction_rule_triggers = []
66+
self.active_message_rule_triggers = []
67+
68+
69+
async def cog_load(self) -> None:
70+
"""Run startup tasks needed when cog is first loaded."""
71+
await self._load_rules()
72+
73+
# Fill in cache with data for later functions to use
74+
if await self.levels_cache.length() == 0:
75+
shuffled_roles = random.sample(LEVEL_ROLES, len(LEVEL_ROLES))
76+
init_threshold_dict = dict.fromkeys(shuffled_roles, 0)
77+
await self.levels_cache.update(init_threshold_dict)
78+
79+
if await self.running.get("value", False):
80+
logger.debug("Starting Rules and Point Renormalization tasks")
81+
await self._cycle_rules_task.start()
82+
await self._calculate_point_thresholds_task.start()
83+
84+
async def _load_rules(self) -> None:
85+
"""
86+
Load and parse levels rules for usage.
87+
88+
If a rule file does not comply with the format
89+
and throws and error, it is skipped over.
90+
"""
91+
total_files_loaded = 0
92+
for toml_file in self.rules_folder_path.glob("*.toml"):
93+
with open(toml_file, "rb") as f:
94+
rule_dict = tomllib.load(f)
95+
96+
rule_name = toml_file.stem
97+
try:
98+
rule_triggers = [RuleTrigger(**rule_trigger) for rule_trigger in rule_dict["rule"]]
99+
rule = LevelRules(rule_name, rule_triggers)
100+
except (TypeError, KeyError):
101+
logger.info(f"{toml_file} not properly formatted, skipping.")
102+
continue
103+
104+
self.rules_all.append(rule)
105+
total_files_loaded += 1
106+
107+
logger.info(f"Total rules loaded: {total_files_loaded}")
108+
109+
@tasks.loop(minutes=42.0)
110+
async def _cycle_rules_task(self) -> None:
111+
"""
112+
Change which rules are currently active.
113+
114+
Rules will statistically be used before a repeat is seen.
115+
This is not a guarnatee though.
116+
"""
117+
if len(self.rules_pool) < self.active_rules_num:
118+
# If pool is empty, reshuffle completely to avoid activating same rule twice
119+
self.rules_pool = random.sample(self.rules_all, len(self.rules_all))
120+
self.rules_active = [self.rules_pool.pop() for _ in range(self.active_rules_num)]
121+
logger.debug(f"Cycled active rules to: {[rule.name for rule in self.rules_active]}")
122+
123+
124+
self.active_message_rule_triggers = [
125+
rule for rule in self.rules_active
126+
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="message"
127+
]
128+
self.active_reaction_rule_triggers = [
129+
rule for rule in self.rules_active
130+
for rule_trigger in rule.rule_triggers if rule_trigger.interaction_type=="reaction"
131+
]
132+
# [rule for rule in self.rules_active if rule.interaction_type=="reaction"]
133+
# self.active_message_rule_triggers = [rule for rule in self.rules_active if rule.interaction_type=="message"]
134+
135+
@tasks.loop(minutes=90.0)
136+
async def _calculate_point_thresholds_task(self) -> None:
137+
"""
138+
Calculate point thresholds based on number of roles, aiming for even deciles based on scores.
139+
140+
If current max score is less than 100, it will fix deciles to increments of 10.
141+
"""
142+
user_points = await self.user_points_cache.to_dict()
143+
all_scores = sorted(user_points.values())
144+
if all_scores and all_scores[-1] >= 100:
145+
num_scores = len(all_scores)
146+
num_levels = len(LEVEL_ROLES)
147+
thresholds = [
148+
all_scores[round(num_scores * level/num_levels)]
149+
for level in range(1, num_levels+1)
150+
]
151+
else:
152+
# At the start of the event, just use multiples of 10 up to 100
153+
thresholds = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
154+
155+
levels = await self.levels_cache.to_dict()
156+
new_levels = dict(zip(levels.keys(), thresholds, strict=False))
157+
await self.levels_cache.update(new_levels)
158+
logger.debug(f"Renormalizing score thresholds. Total scores: {len(all_scores)}")
159+
logger.debug(f"New thresholds: {thresholds}")
160+
161+
162+
async def _update_points(self, user_id: int, points: int) -> None:
163+
"""Updates user's score and ensures correct role is assigned."""
164+
logger.debug(f"User {user_id} getting {points} points.")
165+
if not await self.user_points_cache.contains(user_id):
166+
await self.user_points_cache.set(user_id, points)
167+
else:
168+
if points == 0:
169+
return
170+
171+
current_points = await self.user_points_cache.get(user_id)
172+
new_point_total = current_points + points
173+
await self.user_points_cache.set(user_id, new_point_total)
174+
175+
await self._update_role_assignment(user_id)
176+
177+
178+
async def _update_role_assignment(self, user_id: int) -> None:
179+
"""Updates user's role based on current points and role-point thresholds."""
180+
user_points = await self.user_points_cache.get(user_id)
181+
levels = await self.levels_cache.to_dict()
182+
level_to_assign = None
183+
184+
for role, point_threshold in sorted(levels.items(), key=lambda item: item[1]):
185+
level_to_assign = role
186+
if point_threshold >= user_points:
187+
break
188+
189+
guild = self.bot.get_guild(constants.Bot.guild)
190+
role = guild.get_role(level_to_assign)
191+
user = await members.get_or_fetch_member(guild, user_id)
192+
if role in user.roles:
193+
return
194+
logger.debug(f"Assigning {role.name} to {user.name}")
195+
await members.handle_role_change(user, user.add_roles, role)
196+
197+
198+
@commands.Cog.listener()
199+
async def on_message(self, msg: discord.Message) -> None:
200+
"""Listens to messages and checks against active message rules."""
201+
if not await self.running.get("value", False):
202+
return
203+
if msg.channel.id not in ALLOWED_CHANNELS or msg.author.bot:
204+
return
205+
if len(self.active_message_rules) == 0:
206+
return
207+
208+
total_points = 0
209+
rule_matches = 0
210+
for rule_trigger in self.active_message_rule_triggers:
211+
re_pattern = rule_trigger.message_content
212+
match = re.search(re_pattern, msg.content)
213+
if match:
214+
total_points += rule_trigger.points
215+
rule_matches += 1
216+
217+
# Only update points if they've matched any rules
218+
# If they match multiple rules and earn 0 points,
219+
# that should still get them a role
220+
if rule_matches != 0:
221+
user_id = msg.author.id
222+
await self._update_points(user_id, total_points)
223+
224+
225+
@commands.Cog.listener()
226+
async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None:
227+
"""
228+
Listens for reactions and checks for against active reaction rules.
229+
230+
It will only listen for reactions added to messages within the bot's message cache.
231+
"""
232+
if not await self.running.get("value", False):
233+
return
234+
if reaction.message.channel.id not in ALLOWED_CHANNELS or user.bot:
235+
return
236+
if len(self.active_reaction_rules) == 0:
237+
return
238+
239+
if isinstance(reaction.emoji, str):
240+
emoji_name = reaction.emoji
241+
else:
242+
emoji_name = reaction.emoji.name
243+
244+
total_points = 0
245+
rule_matches = 0
246+
for rule_trigger in self.active_reaction_rule_triggers:
247+
if emoji_name in rule_trigger.reaction_content:
248+
total_points += rule_trigger.points
249+
rule_matches += 1
250+
251+
# Only update points if they've matched any rules
252+
# If they match multiple rules and earn 0 points,
253+
# that should still get them a role
254+
if rule_matches != 0:
255+
await self._update_points(user.id, total_points)
256+
257+
258+
@commands.group(name="levels")
259+
async def levels_command_group(self, ctx: commands.Context) -> None:
260+
"""Levels group command."""
261+
if not ctx.invoked_subcommand:
262+
await self.bot.invoke_help_command(ctx)
263+
264+
265+
@levels_command_group.command()
266+
@in_whitelist(channels=ALLOWED_COMMAND_CHANNELS)
267+
async def points(self, ctx: commands.Context) -> None:
268+
"""Check how many points you've accrued for the Role Level system."""
269+
user_id = ctx.author.id
270+
271+
if await self.user_points_cache.contains(user_id):
272+
points = await self.user_points_cache.get(user_id)
273+
await ctx.reply(f"You have {points} points.")
274+
else:
275+
await ctx.reply("You have not earned any points so far! :D")
276+
277+
278+
@levels_command_group.command()
279+
@commands.has_any_role(*ELEVATED_ROLES)
280+
async def shuffle_role_order(self, ctx: commands.Context) -> None:
281+
"""Shuffle which roles are assigned to which point thresholds."""
282+
levels = await self.levels_cache.to_dict()
283+
thresholds = levels.values()
284+
285+
role_order = random.sample(LEVEL_ROLES, len(LEVEL_ROLES))
286+
updated_ordering = dict(zip(role_order, thresholds, strict=False))
287+
288+
await self.levels_cache.update(updated_ordering)
289+
logger.info(f"Roles have been re-shuffled per request of {ctx.author.name}")
290+
291+
@levels_command_group.command()
292+
@commands.has_any_role(*ELEVATED_ROLES)
293+
async def start(self, ctx: commands.Context) -> None:
294+
"""Allows Levels to run, check messages, and assign roles."""
295+
current_state = await self.running.get("value", False)
296+
if current_state:
297+
await ctx.reply("Levels is already running.")
298+
return
299+
300+
self._cycle_rules_task.start()
301+
self._calculate_point_thresholds_task.start()
302+
await self.running.set("value", True)
303+
await ctx.reply("Levels is now turned on.")
304+
305+
@levels_command_group.command()
306+
@commands.has_any_role(*ELEVATED_ROLES)
307+
async def stop(self, ctx: commands.Context) -> None:
308+
"""Disallows Levels to run, check messages, and assign roles."""
309+
current_state = await self.running.get("value", False)
310+
if not current_state:
311+
await ctx.reply("Levels is already off.")
312+
return
313+
314+
self._cycle_rules_task.cancel()
315+
self._calculate_point_thresholds_task.cancel()
316+
await self.running.set("value", False)
317+
await ctx.reply("Levels is now turned off.")
318+
319+
@levels_command_group.command()
320+
@commands.has_any_role(*ELEVATED_ROLES)
321+
async def status(self, ctx: commands.Context) -> None:
322+
"""Replies with current status of Levels."""
323+
current_state = await self.running.get("value", False)
324+
if current_state:
325+
await ctx.reply(":white_check_mark: Levels is currently running.")
326+
else:
327+
await ctx.reply(":x: Levels is current **not** running.")
328+
329+
330+
# Please see ./rules/README.md for how to format rules
331+
332+
@dataclass
333+
class RuleTrigger:
334+
interaction_type: Literal["message", "reaction"]
335+
reaction_content: list[str] | None = None
336+
message_content: str | None = None
337+
points: int = 0
338+
339+
@dataclass
340+
class LevelRules:
341+
name: str
342+
rule_triggers: list[RuleTrigger]

0 commit comments

Comments
 (0)