Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .test.env
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ ROLE_NOOB=8377301011276950695

ROLE_VIP=9583772810112769506
ROLE_VIP_PLUS=9583772910112769506
ROLE_SILVER_ANNUAL = 1432409954337296384
ROLE_GOLD_ANNUAL = 1432410047782064232

ROLE_CHALLENGE_CREATOR=8215461011276950716
ROLE_BOX_CREATOR=8215471011276950716
Expand Down
16 changes: 14 additions & 2 deletions src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,12 @@ class Roles(BaseSettings):
HACKER: int
SCRIPT_KIDDIE: int
NOOB: int

# Subscriptions
VIP: int
VIP_PLUS: int
SILVER_ANNUAL: int
GOLD_ANNUAL: int

# Content Creation
CHALLENGE_CREATOR: int
Expand Down Expand Up @@ -249,6 +253,8 @@ def get_post_or_rank(self, what: str) -> Optional[int]:
"Challenge Creator": self.roles.CHALLENGE_CREATOR,
"Box Creator": self.roles.BOX_CREATOR,
"Sherlock Creator": self.roles.SHERLOCK_CREATOR,
"Silver Annual": self.roles.SILVER_ANNUAL,
"Gold Annual": self.roles.GOLD_ANNUAL,
}.get(what)

def get_season(self, what: str):
Expand Down Expand Up @@ -336,8 +342,6 @@ def load_settings(env_file: str | None = None):
global_settings.roles.HACKER,
global_settings.roles.SCRIPT_KIDDIE,
global_settings.roles.NOOB,
global_settings.roles.VIP,
global_settings.roles.VIP_PLUS,
],
"ALL_SEASON_RANKS": [
global_settings.roles.SEASON_HOLO,
Expand All @@ -355,6 +359,14 @@ def load_settings(env_file: str | None = None):
global_settings.roles.RANK_ONE,
global_settings.roles.RANK_TEN,
],
"ALL_LABS_SUBSCRIPTIONS": [
global_settings.roles.VIP,
global_settings.roles.VIP_PLUS,
],
"ALL_ACADEMY_SUBSCRIPTIONS": [
global_settings.roles.SILVER_ANNUAL,
global_settings.roles.GOLD_ANNUAL,
]
}

return global_settings
Expand Down
26 changes: 24 additions & 2 deletions src/webhooks/handlers/academy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ async def handle(self, body: WebhookBody, bot: Bot):
"""
if body.event == WebhookEvent.CERTIFICATE_AWARDED:
return await self._handle_certificate_awarded(body, bot)
if body.event == WebhookEvent.SUBSCRIPTION_CHANGE:
return await self._handle_subscription_change(body, bot)
else:
raise ValueError(f"Invalid event: {body.event}")

async def _handle_certificate_awarded(self, body: WebhookBody, bot: Bot) -> dict:
"""
Handles the certificate awarded event.
"""
discord_id = self.validate_discord_id(self.get_property_or_trait(body, "discord_id"))
_ = self.validate_account_id(self.get_property_or_trait(body, "account_id"))
discord_id, _ = self.validate_common_properties(body)
certificate_id = self.validate_property(
self.get_property_or_trait(body, "certificate_id"), "certificate_id"
)
Expand All @@ -48,3 +49,24 @@ async def _handle_certificate_awarded(self, body: WebhookBody, bot: Bot) -> dict
raise e

return self.success()

async def _handle_subscription_change(self, body: WebhookBody, bot: Bot) -> dict:
"""
Handles the subscription change event.
"""
discord_id, _ = self.validate_common_properties(body)
plan = self.validate_property(self.get_property_or_trait(body, "plan"), "plan")

self.logger.info(f"Handling subscription change event for {discord_id} with plan {plan}")

member = await self.get_guild_member(discord_id, bot)
subscription_role_id = settings.get_post_or_rank(plan)
if not subscription_role_id:
self.logger.warning(f"No subscription role found for plan {plan}")
return self.fail()

# Use the base handler's role swapping method
role_group = [int(r) for r in settings.role_groups["ALL_ACADEMY_SUBSCRIPTIONS"]]
await self.swap_role_in_group(member, subscription_role_id, role_group, bot)

return self.success()
9 changes: 3 additions & 6 deletions src/webhooks/handlers/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ async def _handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict:
"""
Handles the account linked event.
"""
discord_id = self.validate_discord_id(self.get_property_or_trait(body, "discord_id"))
account_id = self.validate_account_id(self.get_property_or_trait(body, "account_id"))
discord_id, account_id = self.validate_common_properties(body)

member = await self.get_guild_member(discord_id, bot)
await process_account_identification(
Expand Down Expand Up @@ -67,8 +66,7 @@ async def _handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict:
"""
Handles the account unlinked event.
"""
discord_id = self.validate_discord_id(self.get_property_or_trait(body, "discord_id"))
account_id = self.validate_account_id(self.get_property_or_trait(body, "account_id"))
discord_id, account_id = self.validate_common_properties(body)

member = await self.get_guild_member(discord_id, bot)

Expand All @@ -95,8 +93,7 @@ async def _handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict:
"""
Handles the account banned event.
"""
discord_id = self.validate_discord_id(self.get_property_or_trait(body, "discord_id"))
account_id = self.validate_account_id(self.get_property_or_trait(body, "account_id"))
discord_id, account_id = self.validate_common_properties(body)
expires_at = self.validate_property(self.get_property_or_trait(body, "expires_at"), "expires_at")
reason = body.properties.get("reason")
notes = body.properties.get("notes")
Expand Down
111 changes: 107 additions & 4 deletions src/webhooks/handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from typing import TypeVar

from discord import Bot, Member
from discord import Bot, Member, Role
from discord.errors import NotFound
from fastapi import HTTPException

Expand Down Expand Up @@ -41,7 +41,7 @@ async def get_guild_member(self, discord_id: int | str, bot: Bot) -> Member:
Raises:
HTTPException: If the user is not in the Discord server (400)
"""

try:
guild = await bot.fetch_guild(settings.guild_ids[0])
member = await guild.fetch_member(int(discord_id))
Expand Down Expand Up @@ -119,10 +119,113 @@ def get_platform_properties(self, body: WebhookBody) -> dict[str, int | None]:
}
return properties

async def swap_role_in_group(
self,
member: Member,
new_role_id: int | None,
role_group: list[int],
bot: Bot,
allow_no_role: bool = False,
) -> bool:
"""
Swaps a member's role within a specific role group.

This method removes any existing role from the specified group and adds the new role.

Args:
member: The Discord member to modify
new_role_id: ID of the new role to assign (None to remove all roles from group)
role_group: List of role IDs that are mutually exclusive
bot: The Discord bot instance
allow_no_role: If True, allows removing all roles without adding a new one

Returns:
bool: True if changes were made, False if no changes needed

Raises:
ValueError: If new_role_id is invalid or not in the role group
"""
# Get all roles from the group as Discord Role objects
group_roles = [
bot.guilds[0].get_role(role_id)
for role_id in role_group
if bot.guilds[0].get_role(role_id)
]

# Find current role from this group that the member has
current_role = next(
(role for role in member.roles if role in group_roles), None
)

# Get the new role object if specified
new_role = None
if new_role_id:
new_role = bot.guilds[0].get_role(new_role_id)
if not new_role:
raise ValueError(f"Invalid role ID: {new_role_id}")

# Verify the new role is in the allowed group
if new_role not in group_roles:
raise ValueError(
f"Role {new_role_id} is not in the specified role group"
)

# If no change needed, return early
if current_role == new_role:
return False

# If we're trying to remove all roles but it's not allowed
if not new_role and not allow_no_role and current_role:
raise ValueError(
"Cannot remove role without replacement when allow_no_role=False"
)

# Remove current role if it exists
if current_role:
await member.remove_roles(current_role, atomic=True)

# Add new role if specified
if new_role:
await member.add_roles(new_role, atomic=True)

return True

def validate_common_properties(
self, body: WebhookBody
) -> tuple[int | str, int | str]:
"""
Validates and returns the common discord_id and account_id properties.

Args:
body: The webhook body containing properties/traits

Returns:
tuple: (discord_id, account_id)

Raises:
HTTPException: If either property is missing or invalid
"""
discord_id = self.validate_discord_id(
self.get_property_or_trait(body, "discord_id")
)
account_id = self.validate_account_id(
self.get_property_or_trait(body, "account_id")
)
return discord_id, account_id

async def _find_user_with_role(self, bot: Bot, role: Role | None) -> Member | None:
"""
Finds the user with the given role.
"""
if not role:
return None

return next((m for m in role.members), None)

@staticmethod
def success():
return {"success": True}

@staticmethod
def fail():
return {"success": False}
return {"success": False}
Loading