Skip to content

Commit 55e6e75

Browse files
authored
Merge pull request #789 from allthingslinux/statusroles
feat(status-roles): add cog to assign roles based on user status
2 parents 8fb5714 + 3d257ab commit 55e6e75

File tree

3 files changed

+121
-0
lines changed

3 files changed

+121
-0
lines changed

config/settings.yml.example

+5
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ TEMPVC_CATEGORY_ID: 123456789012345679
4949
# Set this to the channel ID where you want the temporary voice channels to be created.
5050
TEMPVC_CHANNEL_ID: 123456789012345679
5151

52+
# This will automatically give people with a status regex a role.
53+
STATUS_ROLES:
54+
#- server_id: 123456789012345679
55+
# status_regex: ".*"
56+
# role_id: 123456789012345679
5257

5358
SNIPPETS:
5459
LIMIT_TO_ROLE_IDS: false # Only allow users with the specified role IDs to use the snippet command

tux/cogs/services/status_roles.py

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import re
2+
3+
import discord
4+
from discord.ext import commands
5+
from loguru import logger
6+
7+
from tux.utils.config import CONFIG
8+
9+
10+
class StatusRoles(commands.Cog):
11+
"""Assign roles to users based on their status."""
12+
13+
def __init__(self, bot: commands.Bot):
14+
self.bot = bot
15+
self.status_roles = CONFIG.STATUS_ROLES
16+
logger.info("StatusRoles cog initialized with %d role configurations", len(self.status_roles))
17+
18+
@commands.Cog.listener()
19+
async def on_ready(self):
20+
"""Check all users' statuses when the bot starts up."""
21+
logger.info("StatusRoles cog ready, checking all users' statuses")
22+
for guild in self.bot.guilds:
23+
for member in guild.members:
24+
await self.check_and_update_roles(member)
25+
26+
@commands.Cog.listener()
27+
async def on_presence_update(self, before: discord.Member, after: discord.Member):
28+
"""Event triggered when a user's presence changes."""
29+
logger.debug(f"Presence update for {after.display_name}: {before.status} -> {after.status}")
30+
# Only process if the custom status changed
31+
before_status = self.get_custom_status(before)
32+
after_status = self.get_custom_status(after)
33+
34+
if before_status != after_status or self.has_activity_changed(before, after):
35+
logger.debug(f"Status change detected for {after.display_name}: '{before_status}' -> '{after_status}'")
36+
await self.check_and_update_roles(after)
37+
38+
def has_activity_changed(self, before: discord.Member, after: discord.Member) -> bool:
39+
"""Check if there was a relevant change in activities."""
40+
before_has_custom = (
41+
any(isinstance(a, discord.CustomActivity) for a in before.activities) if before.activities else False
42+
)
43+
after_has_custom = (
44+
any(isinstance(a, discord.CustomActivity) for a in after.activities) if after.activities else False
45+
)
46+
return before_has_custom != after_has_custom
47+
48+
def get_custom_status(self, member: discord.Member) -> str | None:
49+
"""Extract the custom status text from a member's activities."""
50+
if not member.activities:
51+
return None
52+
53+
return next(
54+
(
55+
activity.name
56+
for activity in member.activities
57+
if isinstance(activity, discord.CustomActivity) and activity.name
58+
),
59+
None,
60+
)
61+
62+
async def check_and_update_roles(self, member: discord.Member):
63+
"""Check a member's status against configured patterns and update roles accordingly."""
64+
if member.bot:
65+
return
66+
67+
status_text = self.get_custom_status(member)
68+
if status_text is None:
69+
status_text = "" # Use empty string for regex matching if no status
70+
71+
for config in self.status_roles:
72+
# Skip if the config is for a different server
73+
if int(config.get("server_id", 0)) != member.guild.id:
74+
continue
75+
76+
role_id = int(config.get("role_id", 0))
77+
pattern = str(config.get("status_regex", ".*"))
78+
79+
role = member.guild.get_role(role_id)
80+
if not role:
81+
logger.warning(f"Role {role_id} configured in STATUS_ROLES not found in guild {member.guild.name}")
82+
continue
83+
84+
try:
85+
matches = bool(re.search(pattern, status_text, re.IGNORECASE))
86+
87+
has_role = role in member.roles
88+
89+
if matches and not has_role:
90+
# Add role if status matches and member doesn't have the role
91+
logger.info(
92+
f"Adding role {role.name} to {member.display_name} (status: '{status_text}' matched '{pattern}')",
93+
)
94+
await member.add_roles(role, reason="Status role automation")
95+
96+
elif not matches and has_role:
97+
# Remove role if status doesn't match and member has the role
98+
logger.info(f"Removing role {role.name} from {member.display_name} (status no longer matches)")
99+
await member.remove_roles(role, reason="Status role automation")
100+
101+
except re.error:
102+
logger.exception(f"Invalid regex pattern '{pattern}' in STATUS_ROLES config")
103+
except discord.Forbidden:
104+
logger.exception(
105+
f"Bot lacks permission to modify roles for {member.display_name} in {member.guild.name}",
106+
)
107+
except Exception:
108+
logger.exception(f"Error updating roles for {member.display_name}")
109+
110+
111+
async def setup(bot: commands.Bot):
112+
await bot.add_cog(StatusRoles(bot))
113+
logger.info("Loaded StatusRoles cog")

tux/utils/config.py

+3
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ class Config:
5555
ACTIVITIES: Final[str] = config["BOT_INFO"]["ACTIVITIES"]
5656
HIDE_BOT_OWNER: Final[bool] = config["BOT_INFO"]["HIDE_BOT_OWNER"]
5757

58+
# Status Roles
59+
STATUS_ROLES: Final[list[dict[str, int]]] = config["STATUS_ROLES"]
60+
5861
# Debug env
5962
DEBUG: Final[bool] = bool(os.getenv("DEBUG", "True"))
6063

0 commit comments

Comments
 (0)