From 1811c97673c45d81689f87d99e7348e33571781f Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 16 Jun 2025 20:57:09 -0400 Subject: [PATCH 01/29] =?UTF-8?q?=F0=9F=9B=82=20Move=20webhook=20authoriza?= =?UTF-8?q?tion=20to=20HMAC=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/server.py | 53 ++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/src/webhooks/server.py b/src/webhooks/server.py index 92222a7..06d02af 100644 --- a/src/webhooks/server.py +++ b/src/webhooks/server.py @@ -1,10 +1,13 @@ +import hashlib import hmac import logging -from typing import Any, Dict, Union +import json +from typing import Any, Dict -from fastapi import FastAPI, Header, HTTPException +from fastapi import FastAPI, HTTPException, Request from hypercorn.asyncio import serve as hypercorn_serve from hypercorn.config import Config as HypercornConfig +from pydantic import ValidationError from src.bot import bot from src.core import settings @@ -17,20 +20,36 @@ app = FastAPI() +def verify_signature(body: dict, signature: str, secret: str) -> bool: + """ + HMAC SHA1 signature verification. + + Args: + body (dict): The raw body of the webhook request. + signature (str): The X-Signature header of the webhook request. + secret (str): The webhook secret. + + Returns: + bool: True if the signature is valid, False otherwise. + """ + if not signature: + return False + + digest = hmac.new(secret.encode(), body, hashlib.sha1).hexdigest() + return hmac.compare_digest(signature, digest) + + @app.post("/webhook") -async def webhook_handler( - body: WebhookBody, authorization: Union[str, None] = Header(default=None) -) -> Dict[str, Any]: +async def webhook_handler(request: Request) -> Dict[str, Any]: """ Handles incoming webhook requests and forwards them to the appropriate handler. - This function first checks the provided authorization token in the request header. - If the token is valid, it checks if the platform can be handled and then forwards + This function first verifies the provided HMAC signature in the request header. + If the signature is valid, it checks if the platform can be handled and then forwards the request to the corresponding handler. Args: - body (WebhookBody): The data received from the webhook. - authorization (Union[str, None]): The authorization header containing the Bearer token. + request (Request): The incoming webhook request. Returns: Dict[str, Any]: The response from the corresponding handler. The dictionary contains @@ -39,17 +58,21 @@ async def webhook_handler( Raises: HTTPException: If an error occurs while processing the webhook event or if unauthorized. """ - if authorization is None or not authorization.strip().startswith("Bearer"): - logger.warning("Unauthorized webhook request") - raise HTTPException(status_code=401, detail="Unauthorized") + body = await request.body() + signature = request.headers.get("X-Signature") - token = authorization[6:].strip() - if hmac.compare_digest(token, settings.WEBHOOK_TOKEN): + if not verify_signature(body, signature, settings.WEBHOOK_TOKEN): logger.warning("Unauthorized webhook request") raise HTTPException(status_code=401, detail="Unauthorized") + try: + body = WebhookBody.model_validate(json.loads(body)) + except ValidationError as e: + logger.warning("Invalid webhook request: %s", e.errors()) + raise HTTPException(status_code=400, detail="Invalid webhook request body") + if not handlers.can_handle(body.platform): - logger.warning("Webhook request not handled by platform") + logger.warning("Webhook request not handled by platform: %s", body.platform) raise HTTPException(status_code=501, detail="Platform not implemented") return await handlers.handle(body, bot) From 29f5d74cfee31ca4b2e333287d2a6a45dbb466bc Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 16 Jun 2025 20:58:46 -0400 Subject: [PATCH 02/29] =?UTF-8?q?=F0=9F=91=94=20Update=20webhook=20types?= =?UTF-8?q?=20and=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/types.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/webhooks/types.py b/src/webhooks/types.py index 3885dda..9812ccb 100644 --- a/src/webhooks/types.py +++ b/src/webhooks/types.py @@ -1,11 +1,12 @@ from enum import Enum -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class WebhookEvent(Enum): - ACCOUNT_LINKED = "AccountLinked" - ACCOUNT_UNLINKED = "AccountUnlinked" + ACCOUNT_LINKED = "DiscordAccountLinked" + ACCOUNT_UNLINKED = "DiscordAccountUnlinked" + ACCOUNT_DELETED = "UserAccountDeleted" CERTIFICATE_AWARDED = "CertificateAwarded" RANK_UP = "RankUp" HOF_CHANGE = "HofChange" @@ -19,9 +20,13 @@ class Platform(Enum): ACADEMY = "academy" CTF = "ctf" ENTERPRISE = "enterprise" + ACCOUNT = "account" class WebhookBody(BaseModel): + model_config = ConfigDict(extra="allow") + platform: Platform event: WebhookEvent - data: dict + properties: dict | None + traits: dict | None From 04d85b7b6ee43564f23f4002a70fd7679877979c Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 12:20:16 -0400 Subject: [PATCH 03/29] =?UTF-8?q?=E2=9C=A8=20Add=20optional=20code=20param?= =?UTF-8?q?eter=20to=20simple=20response=20for=20easier=20checking=20of=20?= =?UTF-8?q?response=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/helpers/responses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/helpers/responses.py b/src/helpers/responses.py index 6513bfd..91a8213 100644 --- a/src/helpers/responses.py +++ b/src/helpers/responses.py @@ -4,9 +4,10 @@ class SimpleResponse(object): """A simple response object.""" - def __init__(self, message: str, delete_after: int | None = None): + def __init__(self, message: str, delete_after: int | None = None, code: str | None = None): self.message = message self.delete_after = delete_after + self.code = code def __str__(self): return json.dumps(dict(self), ensure_ascii=False) From ed7b624930b2c81af8a802379a0bcb99bf6fda92 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 12:34:30 -0400 Subject: [PATCH 04/29] =?UTF-8?q?=E2=9C=A8=20Introduce=20ban=20code=20in?= =?UTF-8?q?=20SimpleResponse.=20Refactor=20how=20response=20is=20built.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/helpers/ban.py | 63 +++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/src/helpers/ban.py b/src/helpers/ban.py index 8d2ddd0..08a6895 100644 --- a/src/helpers/ban.py +++ b/src/helpers/ban.py @@ -2,6 +2,8 @@ import asyncio import logging from datetime import datetime, timezone +from enum import Enum + import arrow import discord @@ -22,6 +24,12 @@ logger = logging.getLogger(__name__) +class BanCodes(Enum): + SUCCESS = "SUCCESS" + ALREADY_EXISTS = "ALREADY_EXISTS" + FAILED = "FAILED" + + async def _check_member(bot: Bot, guild: Guild, member: Member | User, author: Member = None) -> SimpleResponse | None: if isinstance(member, Member): if member_is_staff(member): @@ -29,9 +37,9 @@ async def _check_member(bot: Bot, guild: Guild, member: Member | User, author: M elif isinstance(member, User): member = await bot.get_member_or_user(guild, member.id) if member.bot: - return SimpleResponse(message="You cannot ban a bot.", delete_after=None) + return SimpleResponse(message="You cannot ban a bot.", delete_after=None, code=BanCodes.FAILED) if author and author.id == member.id: - return SimpleResponse(message="You cannot ban yourself.", delete_after=None) + return SimpleResponse(message="You cannot ban yourself.", delete_after=None, code=BanCodes.FAILED) async def _get_ban_or_create(member: Member, ban: Ban, infraction: Infraction) -> tuple[int, bool]: @@ -50,6 +58,29 @@ async def _get_ban_or_create(member: Member, ban: Ban, infraction: Infraction) - return ban_id, False +async def _create_ban_response(member: Member | User, end_date: str, dm_banned_member: bool, needs_approval: bool) -> SimpleResponse: + """Create a SimpleResponse for ban operations.""" + if needs_approval: + if member: + message = f"{member.display_name} ({member.id}) has been banned until {end_date} (UTC)." + else: + message = f"{member.id} has been banned until {end_date} (UTC)." + else: + if member: + message = f"Member {member.display_name} has been banned permanently." + else: + message = f"Member {member.id} has been banned permanently." + + if not dm_banned_member: + message += "\n Could not DM banned member due to permission error." + + return SimpleResponse( + message=message, + delete_after=0 if not needs_approval else None, + code=BanCodes.SUCCESS + ) + + async def ban_member( bot: Bot, guild: Guild, member: Member | User, duration: str, reason: str, evidence: str, author: Member = None, needs_approval: bool = True @@ -93,7 +124,8 @@ async def ban_member( if is_existing: return SimpleResponse( message=f"A ban with id: {ban_id} already exists for member {member}", - delete_after=None + delete_after=None, + code=BanCodes.ALREADY_EXISTS ) # DM member, before we ban, else we cannot dm since we do not share a guild @@ -107,27 +139,20 @@ async def ban_member( extra={"ban_requestor": author.name, "ban_receiver": member.id} ) if author: - return SimpleResponse(message="You do not have the proper permissions to ban.", delete_after=None) + return SimpleResponse(message="You do not have the proper permissions to ban.", delete_after=None, code=BanCodes.FAILED) return except HTTPException as ex: logger.warning(f"HTTPException when trying to ban user with ID {member.id}", exc_info=ex) if author: return SimpleResponse( message="Here's a 400 Bad Request for you. Just like when you tried to ask me out, last week.", - delete_after=None + delete_after=None, + code=BanCodes.FAILED ) return # If approval is required, send a message to the moderator channel about the ban if not needs_approval: - if member: - message = f"Member {member.display_name} has been banned permanently." - else: - message = f"Member {member.id} has been banned permanently." - - if not dm_banned_member: - message += "\n Could not DM banned member due to permission error." - logger.info( "Member has been banned permanently.", extra={"ban_requestor": author.name, "ban_receiver": member.id, "dm_banned_member": dm_banned_member} @@ -136,16 +161,7 @@ async def ban_member( unban_task = schedule(unban_member(guild, member), run_at=datetime.fromtimestamp(ban.unban_time)) asyncio.create_task(unban_task) logger.debug("Unbanned sceduled for ban", extra={"ban_id": ban_id, "unban_time": ban.unban_time}) - return SimpleResponse(message=message, delete_after=0) else: - if member: - message = f"{member.display_name} ({member.id}) has been banned until {end_date} (UTC)." - else: - message = f"{member.id} has been banned until {end_date} (UTC)." - - if not dm_banned_member: - message += " Could not DM banned member due to permission error." - member_name = f"{member.display_name} ({member.name})" embed = discord.Embed( title=f"Ban request #{ban_id}", @@ -156,7 +172,8 @@ async def ban_member( embed.set_thumbnail(url=f"{settings.HTB_URL}/images/logo600.png") view = BanDecisionView(ban_id, bot, guild, member, end_date, reason) await guild.get_channel(settings.channels.SR_MOD).send(embed=embed, view=view) - return SimpleResponse(message=message) + + return await _create_ban_response(member, end_date, dm_banned_member, needs_approval) async def _dm_banned_member(end_date: str, guild: Guild, member: Member, reason: str) -> bool: From 182e429cb8bb1e10a508f2f455ddc892930baed8 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 12:54:46 -0400 Subject: [PATCH 05/29] =?UTF-8?q?=E2=9E=95=20Add=20VERIFIED=20role=20setti?= =?UTF-8?q?ng=20to=20Roles=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/config.py b/src/core/config.py index 6b3025e..b91644d 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -91,6 +91,7 @@ class AcademyCertificates(BaseSettings): class Roles(BaseSettings): """The roles settings.""" + VERIFIED: int # Moderation COMMUNITY_MANAGER: int From 708e1202156e7cf0a06d2b157a3b25537ad924a2 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 13:18:21 -0400 Subject: [PATCH 06/29] =?UTF-8?q?=E2=9C=A8=20Refactor=20verification=20hel?= =?UTF-8?q?per=20functions.=20Breakout=20primary=20verification=20to=20be?= =?UTF-8?q?=20HTB=20account=20based.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/helpers/verification.py | 210 +++++++++++++++++++++--------------- 1 file changed, 122 insertions(+), 88 deletions(-) diff --git a/src/helpers/verification.py b/src/helpers/verification.py index ddb4be9..9762ebb 100644 --- a/src/helpers/verification.py +++ b/src/helpers/verification.py @@ -1,6 +1,5 @@ import logging -from datetime import datetime -from typing import Dict, List, Optional, cast +from typing import Dict, List, Optional import aiohttp import discord @@ -9,7 +8,7 @@ from src.bot import Bot from src.core import settings -from src.helpers.ban import ban_member +from src.helpers.ban import BanCodes, ban_member logger = logging.getLogger(__name__) @@ -23,10 +22,14 @@ async def get_user_details(account_identifier: str) -> Optional[Dict]: if r.status == 200: response = await r.json() elif r.status == 404: - logger.debug("Account identifier has been regenerated since last identification. Cannot re-verify.") + logger.debug( + "Account identifier has been regenerated since last identification. Cannot re-verify." + ) response = None else: - logger.error(f"Non-OK HTTP status code returned from identifier lookup: {r.status}.") + logger.error( + f"Non-OK HTTP status code returned from identifier lookup: {r.status}." + ) response = None return response @@ -45,7 +48,9 @@ async def get_season_rank(htb_uid: int) -> str | None: logger.error("Invalid Season ID.") response = None else: - logger.error(f"Non-OK HTTP status code returned from identifier lookup: {r.status}.") + logger.error( + f"Non-OK HTTP status code returned from identifier lookup: {r.status}." + ) response = None if not response["data"]: @@ -59,26 +64,19 @@ async def get_season_rank(htb_uid: int) -> str | None: return rank -async def _check_for_ban(uid: str) -> Optional[Dict]: - async with aiohttp.ClientSession() as session: - token_url = f"{settings.API_URL}/discord/{uid}/banned?secret={settings.HTB_API_SECRET}" - async with session.get(token_url) as r: - if r.status == 200: - ban_details = await r.json() - else: - logger.error( - f"Could not fetch ban details for uid {uid}: " - f"non-OK status code returned ({r.status}). Body: {r.content}" - ) - ban_details = None - - return ban_details +async def _check_for_ban(member: Member) -> Optional[Dict]: + """Check if the member is banned.""" + try: + member.guild.get_role(settings.roles.BANNED) + return True + except Forbidden: + return False async def process_certification(certid: str, name: str): """Process certifications.""" cert_api_url = f"{settings.API_V4_URL}/certificate/lookup" - params = {'id': certid, 'name': name} + params = {"id": certid, "name": name} async with aiohttp.ClientSession() as session: async with session.get(cert_api_url, params=params) as r: if r.status == 200: @@ -86,7 +84,9 @@ async def process_certification(certid: str, name: str): elif r.status == 404: return False else: - logger.error(f"Non-OK HTTP status code returned from identifier lookup: {r.status}.") + logger.error( + f"Non-OK HTTP status code returned from identifier lookup: {r.status}." + ) response = None try: certRawName = response["certificates"][0]["name"] @@ -107,7 +107,90 @@ async def process_certification(certid: str, name: str): return cert -async def process_identification( +async def _handle_banned_user(member: Member, bot: Bot): + """Handle banned trait during account linking. + + Args: + member (Member): The member to process. + bot (Bot): The bot instance. + """ + resp = await ban_member( + bot, + member.guild, + member, + "1337w", + ( + "Banned on the HTB Platform. Ban duration could not be determined. " + "Please login to confirm ban details and contact HTB Support to appeal." + ), + None, + needs_approval=False, + ) + if resp.code == BanCodes.SUCCESS: + embed = discord.Embed( + title="Identification error", + description=f"User {member.mention} ({member.id}) was platform banned HTB and thus also here.", + color=0xFF2429, + ) + await member.guild.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) + + +async def _set_nickname(member: Member, nickname: str) -> bool: + """Set the nickname of the member. + + Args: + member (Member): The member to set the nickname for. + nickname (str): The nickname to set. + + Returns: + bool: True if the nickname was set, False otherwise. + """ + try: + await member.edit(nick=nickname) + return True + except Forbidden as e: + logger.error(f"Exception whe trying to edit the nick-name of the user: {e}") + return False + + +async def process_account_identification( + member: Member, bot: Bot, traits: dict[str, str] | None = None +) -> None: + """Process HTB account identification, to be called during account linking. + + Args: + member (Member): The member to process. + bot (Bot): The bot instance. + traits (dict[str, str] | None): Optional user traits to process. + """ + await member.add_roles(member.guild.get_role(settings.roles.VERIFIED), atomic=True) + + nickname_changed = False + + if traits.get("username") and traits.get("username") != member.name: + nickname_changed = await _set_nickname(member, traits.get("username")) + + if not nickname_changed: + logger.warning( + f"No username provided for {member.name} with ID {member.id} during identification." + ) + + if traits.get("mp_user_id"): + htb_user_details = await get_user_details(traits.get("mp_user_id")) + await process_labs_identification(htb_user_details, member, bot) + + if not nickname_changed: + logger.debug( + f"Falling back on HTB username to set nickname for {member.name} with ID {member.id}." + ) + await _set_nickname(member, htb_user_details["username"]) + + if traits.get("banned", False) == True: # noqa: E712 - explicit bool only, no truthiness + await _handle_banned_user(member, bot) + return + + +async def process_labs_identification( htb_user_details: Dict[str, str], user: Optional[Member | User], bot: Bot ) -> Optional[List[Role]]: """Returns roles to assign if identification was successfully processed.""" @@ -123,54 +206,33 @@ async def process_identification( raise MemberNotFound(str(user.id)) else: raise GuildNotFound(f"Could not identify member {user} in guild.") - season_rank = await get_season_rank(htb_uid) - banned_details = await _check_for_ban(htb_uid) - - if banned_details is not None and banned_details["banned"]: - # If user is banned, this field must be a string - # Strip date e.g. from "2022-01-31T11:00:00.000000Z" - banned_until: str = cast(str, banned_details["ends_at"])[:10] - banned_until_dt: datetime = datetime.strptime(banned_until, "%Y-%m-%d") - ban_duration: str = f"{(banned_until_dt - datetime.now()).days}d" - reason = "Banned on the HTB Platform. Please contact HTB Support to appeal." - logger.info(f"Discord user {member.name} ({member.id}) is platform banned. Banning from Discord...") - await ban_member(bot, guild, member, ban_duration, reason, None, needs_approval=False) - - embed = discord.Embed( - title="Identification error", - description=f"User {member.mention} ({member.id}) was platform banned HTB and thus also here.", - color=0xFF2429, ) - - await guild.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) - return None to_remove = [] - for role in member.roles: - if role.id in settings.role_groups.get("ALL_RANKS") + settings.role_groups.get("ALL_POSITIONS"): + if role.id in settings.role_groups.get("ALL_RANKS") + settings.role_groups.get( + "ALL_POSITIONS" + ): to_remove.append(guild.get_role(role.id)) to_assign = [] - logger.debug( - "Getting role 'rank':", extra={ - "role_id": settings.get_post_or_rank(htb_user_details["rank"]), - "role_obj": guild.get_role(settings.get_post_or_rank(htb_user_details["rank"])), - "htb_rank": htb_user_details["rank"], - }, ) - if htb_user_details["rank"] not in ["Deleted", "Moderator", "Ambassador", "Admin", "Staff"]: - to_assign.append(guild.get_role(settings.get_post_or_rank(htb_user_details["rank"]))) + if htb_user_details["rank"] not in [ + "Deleted", + "Moderator", + "Ambassador", + "Admin", + "Staff", + ]: + to_assign.append( + guild.get_role(settings.get_post_or_rank(htb_user_details["rank"])) + ) + + season_rank = await get_season_rank(htb_uid) if season_rank: to_assign.append(guild.get_role(settings.get_season(season_rank))) + if htb_user_details["vip"]: - logger.debug( - 'Getting role "VIP":', extra={"role_id": settings.roles.VIP, "role_obj": guild.get_role(settings.roles.VIP)} - ) to_assign.append(guild.get_role(settings.roles.VIP)) if htb_user_details["dedivip"]: - logger.debug( - 'Getting role "VIP+":', - extra={"role_id": settings.roles.VIP_PLUS, "role_obj": guild.get_role(settings.roles.VIP_PLUS)} - ) to_assign.append(guild.get_role(settings.roles.VIP_PLUS)) if htb_user_details["hof_position"] != "unranked": position = int(htb_user_details["hof_position"]) @@ -180,45 +242,17 @@ async def process_identification( elif position <= 10: pos_top = "10" if pos_top: - logger.debug(f"User is Hall of Fame rank {position}. Assigning role Top-{pos_top}...") - logger.debug( - 'Getting role "HoF role":', extra={ - "role_id": settings.get_post_or_rank(pos_top), - "role_obj": guild.get_role(settings.get_post_or_rank(pos_top)), "hof_val": pos_top, - }, ) to_assign.append(guild.get_role(settings.get_post_or_rank(pos_top))) - else: - logger.debug(f"User is position {position}. No Hall of Fame roles for them.") if htb_user_details["machines"]: - logger.debug( - 'Getting role "BOX_CREATOR":', - extra={"role_id": settings.roles.BOX_CREATOR, "role_obj": guild.get_role(settings.roles.BOX_CREATOR)}, ) to_assign.append(guild.get_role(settings.roles.BOX_CREATOR)) if htb_user_details["challenges"]: - logger.debug( - 'Getting role "CHALLENGE_CREATOR":', extra={ - "role_id": settings.roles.CHALLENGE_CREATOR, - "role_obj": guild.get_role(settings.roles.CHALLENGE_CREATOR), - }, ) to_assign.append(guild.get_role(settings.roles.CHALLENGE_CREATOR)) - if member.nick != htb_user_details["user_name"]: - try: - await member.edit(nick=htb_user_details["user_name"]) - except Forbidden as e: - logger.error(f"Exception whe trying to edit the nick-name of the user: {e}") - - logger.debug("All roles to_assign:", extra={"to_assign": to_assign}) # We don't need to remove any roles that are going to be assigned again to_remove = list(set(to_remove) - set(to_assign)) - logger.debug("All roles to_remove:", extra={"to_remove": to_remove}) if to_remove: await member.remove_roles(*to_remove, atomic=True) - else: - logger.debug("No roles need to be removed") if to_assign: await member.add_roles(*to_assign, atomic=True) - else: - logger.debug("No roles need to be assigned") return to_assign From 4600cc47f453176f54cdeb9e68e5186af26668f6 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 13:24:57 -0400 Subject: [PATCH 07/29] =?UTF-8?q?=F0=9F=94=A7=20Temporarily=20disable=20re?= =?UTF-8?q?-verification=20process=20in=20MessageHandler.=20Added=20a=20pl?= =?UTF-8?q?aceholder=20for=20future=20implementation=20to=20fetch=20link?= =?UTF-8?q?=20state=20from=20HTB=20Account.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cmds/automation/auto_verify.py | 37 ++++++------------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/src/cmds/automation/auto_verify.py b/src/cmds/automation/auto_verify.py index 606ad3e..98c538d 100644 --- a/src/cmds/automation/auto_verify.py +++ b/src/cmds/automation/auto_verify.py @@ -2,12 +2,8 @@ from discord import Member, Message, User from discord.ext import commands -from sqlalchemy import select from src.bot import Bot -from src.database.models import HtbDiscordLink -from src.database.session import AsyncSessionLocal -from src.helpers.verification import get_user_details, process_identification logger = logging.getLogger(__name__) @@ -19,31 +15,11 @@ def __init__(self, bot: Bot): self.bot = bot async def process_reverification(self, member: Member | User) -> None: - """Re-verifation process for a member.""" - async with AsyncSessionLocal() as session: - stmt = ( - select(HtbDiscordLink) - .where(HtbDiscordLink.discord_user_id == member.id) - .order_by(HtbDiscordLink.id) - .limit(1) - ) - result = await session.scalars(stmt) - htb_discord_link: HtbDiscordLink = result.first() - - if not htb_discord_link: - raise VerificationError(f"HTB Discord link for user {member.name} with ID {member}") - - member_token: str = htb_discord_link.account_identifier - - if member_token is None: - raise VerificationError(f"HTB account identifier for user {member.name} with ID {member.id} not found") - - logger.debug(f"Processing re-verify of member {member.name} ({member.id}).") - htb_details = await get_user_details(member_token) - if htb_details is None: - raise VerificationError(f"Retrieving user details for user {member.name} with ID {member.id} failed") - - await process_identification(htb_details, user=member, bot=self.bot) + """Re-verifation process for a member. + + TODO: Reimplement once it's possible to fetch link state from the HTB Account. + """ + raise VerificationError("Not implemented") @commands.Cog.listener() @commands.cooldown(1, 60, commands.BucketType.user) @@ -74,4 +50,5 @@ class VerificationError(Exception): def setup(bot: Bot) -> None: """Load the `MessageHandler` cog.""" - bot.add_cog(MessageHandler(bot)) + # bot.add_cog(MessageHandler(bot)) + pass From 982b1ae2fffc571f9337c1e1cb6390b4bbdc2f0b Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 13:52:22 -0400 Subject: [PATCH 08/29] =?UTF-8?q?=E2=9C=A8=20Verification=20and=20identify?= =?UTF-8?q?=20commands=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rewrote the verification instructions and moved them to their own helper function 2. Replaced both the identify and verify commands to send these new instructions --- src/cmds/core/identify.py | 127 +++--------------------------------- src/cmds/core/verify.py | 54 +-------------- src/helpers/verification.py | 70 +++++++++++++++++++- 3 files changed, 79 insertions(+), 172 deletions(-) diff --git a/src/cmds/core/identify.py b/src/cmds/core/identify.py index ef52ed7..34a378a 100644 --- a/src/cmds/core/identify.py +++ b/src/cmds/core/identify.py @@ -1,17 +1,12 @@ import logging -from typing import Sequence -import discord from discord import ApplicationContext, Interaction, WebhookMessage, slash_command from discord.ext import commands from discord.ext.commands import cooldown -from sqlalchemy import select from src.bot import Bot from src.core import settings -from src.database.models import HtbDiscordLink -from src.database.session import AsyncSessionLocal -from src.helpers.verification import get_user_details, process_identification +from src.helpers.verification import send_verification_instructions logger = logging.getLogger(__name__) @@ -25,121 +20,15 @@ def __init__(self, bot: Bot): @slash_command( guild_ids=settings.guild_ids, description="Identify yourself on the HTB Discord server by linking your HTB account ID to your Discord user " - "ID.", guild_only=False + "ID.", + guild_only=False, ) @cooldown(1, 60, commands.BucketType.user) - async def identify(self, ctx: ApplicationContext, account_identifier: str) -> Interaction | WebhookMessage: - """Identify yourself on the HTB Discord server by linking your HTB account ID to your Discord user ID.""" - if len(account_identifier) != 60: - return await ctx.respond( - "This Account Identifier does not appear to be the right length (must be 60 characters long).", - ephemeral=True - ) - - await ctx.respond("Identification initiated, please wait...", ephemeral=True) - htb_user_details = await get_user_details(account_identifier) - if htb_user_details is None: - embed = discord.Embed(title="Error: Invalid account identifier.", color=0xFF0000) - return await ctx.respond(embed=embed, ephemeral=True) - - json_htb_user_id = htb_user_details["user_id"] - - author = ctx.user - member = await self.bot.get_or_fetch_user(author.id) - if not member: - return await ctx.respond(f"Error getting guild member with id: {author.id}.") - - # Step 1: Check if the Account Identifier has already been recorded and if they are the previous owner. - # Scenario: - # - I create a new Discord account. - # - I reuse my previous Account Identifier. - # - I now have an "alt account" with the same roles. - async with AsyncSessionLocal() as session: - stmt = ( - select(HtbDiscordLink) - .filter(HtbDiscordLink.account_identifier == account_identifier) - .order_by(HtbDiscordLink.id.desc()) - .limit(1) - ) - result = await session.scalars(stmt) - most_recent_rec: HtbDiscordLink = result.first() - - if most_recent_rec and most_recent_rec.discord_user_id_as_int != member.id: - error_desc = ( - f"Verified user {member.mention} tried to identify as another identified user.\n" - f"Current Discord UID: {member.id}\n" - f"Other Discord UID: {most_recent_rec.discord_user_id}\n" - f"Related HTB UID: {most_recent_rec.htb_user_id}" - ) - embed = discord.Embed(title="Identification error", description=error_desc, color=0xFF2429) - await self.bot.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) - - return await ctx.respond( - "Identification error: please contact an online Moderator or Administrator for help.", ephemeral=True - ) - - # Step 2: Given the htb_user_id from JSON, check if each discord_user_id are different from member.id. - # Scenario: - # - I have a Discord account that is linked already to a "Hacker" role. - # - I create a new HTB account. - # - I identify with the new account. - # - `SELECT * FROM htb_discord_link WHERE htb_user_id = %s` will be empty, - # because the new account has not been verified before. All is good. - # - I am now "Noob" rank. - async with AsyncSessionLocal() as session: - stmt = select(HtbDiscordLink).filter(HtbDiscordLink.htb_user_id == json_htb_user_id) - result = await session.scalars(stmt) - user_links: Sequence[HtbDiscordLink] = result.all() - - discord_user_ids = {u_link.discord_user_id_as_int for u_link in user_links} - if discord_user_ids and member.id not in discord_user_ids: - orig_discord_ids = ", ".join([f"<@{id_}>" for id_ in discord_user_ids]) - error_desc = (f"The HTB account {json_htb_user_id} attempted to be identified by user <@{member.id}>, " - f"but is tied to another Discord account.\n" - f"Originally linked to Discord UID {orig_discord_ids}.") - embed = discord.Embed(title="Identification error", description=error_desc, color=0xFF2429) - await self.bot.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) - - return await ctx.respond( - "Identification error: please contact an online Moderator or Administrator for help.", ephemeral=True - ) - - # Step 3: Check if discord_user_id already linked to an htb_user_id, and if JSON/db HTB IDs are the same. - # Scenario: - # - I have a new, unlinked Discord account. - # - Clubby generates a new token and gives it to me. - # - `SELECT * FROM htb_discord_link WHERE discord_user_id = %s` - # will be empty because I have not identified before. - # - I am now Clubby. - async with AsyncSessionLocal() as session: - stmt = select(HtbDiscordLink).filter(HtbDiscordLink.discord_user_id == member.id) - result = await session.scalars(stmt) - user_links: Sequence[HtbDiscordLink] = result.all() - - user_htb_ids = {u_link.htb_user_id_as_int for u_link in user_links} - if user_htb_ids and json_htb_user_id not in user_htb_ids: - error_desc = (f"User {member.mention} ({member.id}) tried to identify with a new HTB account.\n" - f"Original HTB UIDs: {', '.join([str(i) for i in user_htb_ids])}, new HTB UID: " - f"{json_htb_user_id}.") - embed = discord.Embed(title="Identification error", description=error_desc, color=0xFF2429) - await self.bot.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) - - return await ctx.respond( - "Identification error: please contact an online Moderator or Administrator for help.", ephemeral=True - ) - - htb_discord_link = HtbDiscordLink( - account_identifier=account_identifier, discord_user_id=member.id, htb_user_id=json_htb_user_id - ) - async with AsyncSessionLocal() as session: - session.add(htb_discord_link) - await session.commit() - - await process_identification(htb_user_details, user=member, bot=self.bot) - - return await ctx.respond( - f"Your Discord user has been successfully identified as HTB user {json_htb_user_id}.", ephemeral=True - ) + async def identify( + self, ctx: ApplicationContext, account_identifier: str + ) -> Interaction | WebhookMessage: + """Legacy command. Now sends instructions to identify with HTB account.""" + await send_verification_instructions(ctx, ctx.author) def setup(bot: Bot) -> None: diff --git a/src/cmds/core/verify.py b/src/cmds/core/verify.py index 23088c1..a649a53 100644 --- a/src/cmds/core/verify.py +++ b/src/cmds/core/verify.py @@ -8,7 +8,7 @@ from src.bot import Bot from src.core import settings -from src.helpers.verification import process_certification +from src.helpers.verification import process_certification, send_verification_instructions logger = logging.getLogger(__name__) @@ -47,57 +47,7 @@ async def verifycertification(self, ctx: ApplicationContext, certid: str, fullna @cooldown(1, 60, commands.BucketType.user) async def verify(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: """Receive instructions in a DM on how to identify yourself with your HTB account.""" - member = ctx.user - - # Step one - embed_step1 = discord.Embed(color=0x9ACC14) - embed_step1.add_field( - name="Step 1: Log in at Hack The Box", - value="Go to the Hack The Box website at " - " and navigate to **Login > HTB Labs**. Log in to your HTB Account." - , inline=False, ) - embed_step1.set_image( - url="https://media.discordapp.net/attachments/724587782755844098/839871275627315250/unknown.png" - ) - - # Step two - embed_step2 = discord.Embed(color=0x9ACC14) - embed_step2.add_field( - name="Step 2: Locate the Account Identifier", - value='Click on your profile name, then select **My Profile**. ' - 'In the Profile Settings tab, find the field labeled **Account Identifier**. () ' - "Click the green button to copy your secret identifier.", inline=False, ) - embed_step2.set_image( - url="https://media.discordapp.net/attachments/724587782755844098/839871332963188766/unknown.png" - ) - - # Step three - embed_step3 = discord.Embed(color=0x9ACC14) - embed_step3.add_field( - name="Step 3: Identification", - value="Now type `/identify IDENTIFIER_HERE` in the bot-commands channel.\n\nYour roles will be " - "applied automatically.", inline=False - ) - embed_step3.set_image( - url="https://media.discordapp.net/attachments/709907130102317093/904744444539076618/unknown.png" - ) - - try: - await member.send(embed=embed_step1) - await member.send(embed=embed_step2) - await member.send(embed=embed_step3) - except Forbidden as ex: - logger.error("Exception during verify call", exc_info=ex) - return await ctx.respond( - "Whoops! I cannot DM you after all due to your privacy settings. Please allow DMs from other server " - "members and try again in 1 minute." - ) - except HTTPException as ex: - logger.error("Exception during verify call.", exc_info=ex) - return await ctx.respond( - "An unexpected error happened (HTTP 400, bad request). Please contact an Administrator." - ) - return await ctx.respond("Please check your DM for instructions.", ephemeral=True) + await send_verification_instructions(ctx, ctx.author) def setup(bot: Bot) -> None: diff --git a/src/helpers/verification.py b/src/helpers/verification.py index 9762ebb..ae02038 100644 --- a/src/helpers/verification.py +++ b/src/helpers/verification.py @@ -3,7 +3,7 @@ import aiohttp import discord -from discord import Forbidden, Member, Role, User +from discord import ApplicationContext, Forbidden, Member, Role, User, HTTPException from discord.ext.commands import GuildNotFound, MemberNotFound from src.bot import Bot @@ -13,6 +13,74 @@ logger = logging.getLogger(__name__) +async def send_verification_instructions( + ctx: ApplicationContext, member: Member +) -> discord.Interaction | discord.WebhookMessage: + """Send instructions via DM on how to identify with HTB account. + + Args: + ctx (ApplicationContext): The context of the command. + member (Member): The member to send the instructions to. + + Returns: + discord.Interaction | discord.WebhookMessage: The response message. + """ + member = ctx.user + + # Create step-by-step instruction embeds + embed_step1 = discord.Embed(color=0x9ACC14) + embed_step1.add_field( + name="Step 1: Login to your HTB Account", + value="Go to and login.", + inline=False, + ) + embed_step1.set_image( + url="https://media.discordapp.net/attachments/1102700815493378220/1384587341338902579/image.png" + ) + + embed_step2 = discord.Embed(color=0x9ACC14) + embed_step2.add_field( + name="Step 2: Navigate to your Security Settings", + value="In the navigation bar, click on **Security Settings** and scroll down to the **Discord Account** section. " + "()", + inline=False, + ) + embed_step2.set_image( + url="https://media.discordapp.net/attachments/1102700815493378220/1384587813760270392/image.png" + ) + + embed_step3 = discord.Embed(color=0x9ACC14) + embed_step3.add_field( + name="Step 3: Link your Discord Account", + value="Click **Connect** and you will be redirected to login to your Discord account via oauth. " + "After logging in, you will be redirected back to the HTB Account page. " + "Your Discord account will now be linked. Discord may take a few minutes to update. " + "If you have any issues, please contact a Moderator.", + inline=False, + ) + embed_step3.set_image( + url="https://media.discordapp.net/attachments/1102700815493378220/1384586811384402042/image.png" + ) + + try: + await member.send(embed=embed_step1) + await member.send(embed=embed_step2) + await member.send(embed=embed_step3) + except Forbidden as ex: + logger.error("Exception during verify call", exc_info=ex) + return await ctx.respond( + "Whoops! I cannot DM you after all due to your privacy settings. Please allow DMs from other server " + "members and try again in 1 minute." + ) + except HTTPException as ex: + logger.error("Exception during verify call.", exc_info=ex) + return await ctx.respond( + "An unexpected error happened (HTTP 400, bad request). Please contact an Administrator." + ) + + return await ctx.respond("Please check your DM for instructions.", ephemeral=True) + + async def get_user_details(account_identifier: str) -> Optional[Dict]: """Get user details from HTB.""" acc_id_url = f"{settings.API_URL}/discord/identifier/{account_identifier}?secret={settings.HTB_API_SECRET}" From 378599ffb429a3bb2a539a516dbf5cd374d48fda Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 13:52:58 -0400 Subject: [PATCH 09/29] =?UTF-8?q?=F0=9F=A7=B9=20Clean=20up=20imports=20in?= =?UTF-8?q?=20verify.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cmds/core/verify.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cmds/core/verify.py b/src/cmds/core/verify.py index a649a53..919f872 100644 --- a/src/cmds/core/verify.py +++ b/src/cmds/core/verify.py @@ -1,8 +1,6 @@ import logging -import discord from discord import ApplicationContext, Interaction, WebhookMessage, slash_command -from discord.errors import Forbidden, HTTPException from discord.ext import commands from discord.ext.commands import cooldown From c0db0cf96f4e68d10635f85602ca43db6df4d971 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 14:15:33 -0400 Subject: [PATCH 10/29] =?UTF-8?q?=E2=9C=A8=20Implement=20BaseHandler=20cla?= =?UTF-8?q?ss=20for=20webhook=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/base.py | 114 ++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/webhooks/handlers/base.py diff --git a/src/webhooks/handlers/base.py b/src/webhooks/handlers/base.py new file mode 100644 index 0000000..78928bb --- /dev/null +++ b/src/webhooks/handlers/base.py @@ -0,0 +1,114 @@ +import logging + +from abc import ABC, abstractmethod +from typing import TypeVar + +from discord import Bot, Member +from discord.errors import NotFound +from fastapi import HTTPException + +from src.core import settings +from src.webhooks.types import WebhookBody + +T = TypeVar("T") + + +class BaseHandler(ABC): + ACADEMY_USER_ID = "academy_user_id" + MP_USER_ID = "mp_user_id" + EP_USER_ID = "ep_user_id" + CTF_USER_ID = "ctf_user_id" + ACCOUNT_ID = "account_id" + DISCORD_ID = "discord_id" + + + def __init__(self): + self.logger = logging.getLogger(self.__name__) + + @abstractmethod + async def handler(self, body: WebhookBody, bot: Bot) -> dict: + pass + + async def get_guild_member(self, discord_id: int, bot: Bot) -> Member: + """ + Fetches a guild member from the Discord server. + + Args: + discord_id (int): The Discord ID of the user. + bot (Bot): The Discord bot instance. + + Returns: + Member: The guild 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(discord_id) + return member + + except NotFound as exc: + self.logger.debug("User is not in the Discord server", exc_info=exc) + raise HTTPException( + status_code=400, detail="User is not in the Discord server" + ) from exc + + def validate_property(self, property: T | None, name: str) -> T: + """ + Validates a property is not None. + + Args: + property (T | None): The property to validate. + name (str): The name of the property. + + Returns: + T: The validated property. + + Raises: + HTTPException: If the property is None (400) + """ + if property is None: + msg = f"Invalid {name}" + self.logger.debug(msg) + raise HTTPException(status_code=400, detail=msg) + + return property + + def validate_discord_id(self, discord_id: str | int) -> int: + """ + Validates the Discord ID. See validate_property function. + """ + return self.validate_property(discord_id, "Discord ID") + + def validate_account_id(self, account_id: str | int) -> int: + """ + Validates the Account ID. See validate_property function. + """ + return self.validate_property(account_id, "Account ID") + + def get_property_or_trait(self, body: WebhookBody, name: str) -> int | None: + """ + Gets a trait or property from the webhook body. + """ + return body.properties.get(name) or body.traits.get(name) + + def merge_properties_and_traits(self, properties: dict[str, int | None], traits: dict[str, int | None]) -> dict[str, int | None]: + """ + Merges the properties and traits from the webhook body without duplicates. + If a property and trait have the same name but different values, the property value will be used. + """ + return {**properties, **{k: v for k, v in traits.items() if k not in properties}} + + def get_platform_properties(self, body: WebhookBody) -> dict[str, int | None]: + """ + Gets the platform properties from the webhook body. + """ + properties = { + self.ACCOUNT_ID: self.get_property_or_trait(body, self.ACCOUNT_ID), + self.MP_USER_ID: self.get_property_or_trait(body, self.MP_USER_ID), + self.EP_USER_ID: self.get_property_or_trait(body, self.EP_USER_ID), + self.CTF_USER_ID: self.get_property_or_trait(body, self.CTF_USER_ID), + self.ACADEMY_USER_ID: self.get_property_or_trait(body, self.ACADEMY_USER_ID), + } + return properties \ No newline at end of file From 4fe58df6fb203ab43b206e71f04088c233918f89 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 14:15:41 -0400 Subject: [PATCH 11/29] =?UTF-8?q?=E2=9C=A8=20Add=20AccountHandler=20for=20?= =?UTF-8?q?processing=20account-related=20webhook=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/__init__.py | 4 +-- src/webhooks/handlers/account.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/webhooks/handlers/account.py diff --git a/src/webhooks/handlers/__init__.py b/src/webhooks/handlers/__init__.py index 02e0edf..75fade4 100644 --- a/src/webhooks/handlers/__init__.py +++ b/src/webhooks/handlers/__init__.py @@ -1,9 +1,9 @@ from discord import Bot -from src.webhooks.handlers.academy import handler as academy_handler +from src.webhooks.handlers.account import AccountHandler from src.webhooks.types import Platform, WebhookBody -handlers = {Platform.ACADEMY: academy_handler} +handlers = {Platform.ACCOUNT: AccountHandler.handle} def can_handle(platform: Platform) -> bool: diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py new file mode 100644 index 0000000..0a85f89 --- /dev/null +++ b/src/webhooks/handlers/account.py @@ -0,0 +1,48 @@ +from discord import Bot + +from src.core import settings +from src.helpers.verification import process_account_identification +from src.webhooks.types import WebhookBody, WebhookEvent + +from src.webhooks.handlers.base import BaseHandler + + + +class AccountHandler(BaseHandler): + async def handle(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles incoming webhook events and performs actions accordingly. + + This function processes different webhook events originating from the + HTB Account. + """ + if body.event == WebhookEvent.ACCOUNT_LINKED: + await self.handle_account_linked(body, bot) + elif body.event == WebhookEvent.ACCOUNT_UNLINKED: + await self.handle_account_unlinked(body, bot) + elif body.event == WebhookEvent.ACCOUNT_DELETED: + await self.handle_account_deleted(body, bot) + + async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the account linked event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + account_id = self.validate_account_id(body.properties.get("account_id")) + + member = await self.get_guild_member(discord_id, bot) + await process_account_identification(member, bot, traits=self.merge_properties_and_traits(body.properties, body.traits)) + await bot.send_message(settings.channels.VERIFY_LOGS, f"Account linked: {account_id} -> ({member.mention} ({member.id})") + + self.logger.info(f"Account {account_id} linked to {member.id}", extra={"account_id": account_id, "discord_id": discord_id}) + + async def handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the account unlinked event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + account_id = self.validate_account_id(body.properties.get("account_id")) + + member = await self.get_guild_member(discord_id, bot) + + await member.remove_roles(settings.roles.VERIFIED, atomic=True) From ad7c9c80e4fb6d20e11f58c7f65407b02e12d888 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 14:15:53 -0400 Subject: [PATCH 12/29] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20Remove=20academy?= =?UTF-8?q?=20webhook=20handler=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/academy.py | 76 -------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 src/webhooks/handlers/academy.py diff --git a/src/webhooks/handlers/academy.py b/src/webhooks/handlers/academy.py deleted file mode 100644 index 5aa3568..0000000 --- a/src/webhooks/handlers/academy.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging - -from discord import Bot -from discord.errors import NotFound -from fastapi import HTTPException - -from src.core import settings -from src.webhooks.types import WebhookBody, WebhookEvent - -logger = logging.getLogger(__name__) - - -async def handler(body: WebhookBody, bot: Bot) -> dict: - """ - Handles incoming webhook events and performs actions accordingly. - - This function processes different webhook events related to account linking, - certificate awarding, and account unlinking. It updates the member's roles - based on the received event. - - Args: - body (WebhookBody): The data received from the webhook. - bot (Bot): The instance of the Discord bot. - - Returns: - dict: A dictionary with a "success" key indicating whether the operation was successful. - - Raises: - HTTPException: If an error occurs while processing the webhook event. - """ - # TODO: Change it here so we pass the guild instead of the bot # noqa: T000 - guild = await bot.fetch_guild(settings.guild_ids[0]) - - try: - discord_id = int(body.data["discord_id"]) - member = await guild.fetch_member(discord_id) - except ValueError as exc: - logger.debug("Invalid Discord ID", exc_info=exc) - raise HTTPException(status_code=400, detail="Invalid Discord ID") from exc - except NotFound as exc: - logger.debug("User is not in the Discord server", exc_info=exc) - raise HTTPException(status_code=400, detail="User is not in the Discord server") from exc - - if body.event == WebhookEvent.ACCOUNT_LINKED: - roles_to_add = {settings.roles.ACADEMY_USER} - roles_to_add.update(settings.get_academy_cert_role(cert["id"]) for cert in body.data["certifications"]) - - # Filter out invalid role IDs - role_ids_to_add = {role_id for role_id in roles_to_add if role_id is not None} - roles_to_add = {guild.get_role(role_id) for role_id in role_ids_to_add} - - await member.add_roles(*roles_to_add, atomic=True) - elif body.event == WebhookEvent.CERTIFICATE_AWARDED: - cert_id = body.data["certification"]["id"] - - role = settings.get_academy_cert_role(cert_id) - if not role: - logger.debug(f"Role for certification: {cert_id} does not exist") - raise HTTPException(status_code=400, detail=f"Role for certification: {cert_id} does not exist") - - await member.add_roles(role, atomic=True) - elif body.event == WebhookEvent.ACCOUNT_UNLINKED: - current_role_ids = {role.id for role in member.roles} - cert_role_ids = {settings.get_academy_cert_role(cert_id) for _, cert_id in settings.academy_certificates} - - common_role_ids = current_role_ids.intersection(cert_role_ids) - - role_ids_to_remove = {settings.roles.ACADEMY_USER}.union(common_role_ids) - roles_to_remove = {guild.get_role(role_id) for role_id in role_ids_to_remove} - - await member.remove_roles(*roles_to_remove, atomic=True) - else: - logger.debug(f"Event {body.event} not implemented") - raise HTTPException(status_code=501, detail=f"Event {body.event} not implemented") - - return {"success": True} From 6bd6bbfc22a1790fc00e6d80239d12e8b4ab712d Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 17:31:26 -0400 Subject: [PATCH 13/29] =?UTF-8?q?=F0=9F=A9=B9=20Misc=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/__init__.py | 2 +- src/webhooks/handlers/account.py | 2 ++ src/webhooks/handlers/base.py | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/webhooks/handlers/__init__.py b/src/webhooks/handlers/__init__.py index 75fade4..601943c 100644 --- a/src/webhooks/handlers/__init__.py +++ b/src/webhooks/handlers/__init__.py @@ -3,7 +3,7 @@ from src.webhooks.handlers.account import AccountHandler from src.webhooks.types import Platform, WebhookBody -handlers = {Platform.ACCOUNT: AccountHandler.handle} +handlers = {Platform.ACCOUNT: AccountHandler().handle} def can_handle(platform: Platform) -> bool: diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 0a85f89..895cc9f 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -1,3 +1,5 @@ +import logging + from discord import Bot from src.core import settings diff --git a/src/webhooks/handlers/base.py b/src/webhooks/handlers/base.py index 78928bb..cfb63d9 100644 --- a/src/webhooks/handlers/base.py +++ b/src/webhooks/handlers/base.py @@ -23,10 +23,10 @@ class BaseHandler(ABC): def __init__(self): - self.logger = logging.getLogger(self.__name__) + self.logger = logging.getLogger(self.__class__.__name__) @abstractmethod - async def handler(self, body: WebhookBody, bot: Bot) -> dict: + async def handle(self, body: WebhookBody, bot: Bot) -> dict: pass async def get_guild_member(self, discord_id: int, bot: Bot) -> Member: From 42c865a17c8d1fd21688ebe75f034983e5473601 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 17:32:27 -0400 Subject: [PATCH 14/29] Add ROLE_VERIFIED to .test.env (fake ID) --- .test.env | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.test.env b/.test.env index def9d16..dfc9088 100644 --- a/.test.env +++ b/.test.env @@ -32,6 +32,8 @@ CHANNEL_SPOILER=2769521890099371011 CHANNEL_BOT_LOGS=1105517088266788925 # Roles +ROLE_VERIFIED=1333333333333333337 + ROLE_BIZCTF2022=7629466241011276950 ROLE_NOAH_GANG=6706800691011276950 ROLE_BUDDY_GANG=6706800681011276950 From 209851c6e6d981c5efc3b004407fb9d768cf956f Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Tue, 17 Jun 2025 17:32:47 -0400 Subject: [PATCH 15/29] =?UTF-8?q?=E2=9C=85=20Add=20webhook=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/src/webhooks/handlers/test_account.py | 364 ++++++++++++++++++++ tests/src/webhooks/handlers/test_base.py | 315 +++++++++++++++++ tests/src/webhooks/test_handlers_init.py | 35 ++ 3 files changed, 714 insertions(+) create mode 100644 tests/src/webhooks/handlers/test_account.py create mode 100644 tests/src/webhooks/handlers/test_base.py create mode 100644 tests/src/webhooks/test_handlers_init.py diff --git a/tests/src/webhooks/handlers/test_account.py b/tests/src/webhooks/handlers/test_account.py new file mode 100644 index 0000000..91cf195 --- /dev/null +++ b/tests/src/webhooks/handlers/test_account.py @@ -0,0 +1,364 @@ +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from discord import Bot, Member +from discord.errors import NotFound +from fastapi import HTTPException + +from src.webhooks.handlers.account import AccountHandler +from src.webhooks.types import WebhookBody, Platform, WebhookEvent +from tests import helpers + + +class TestAccountHandler: + """Test the `AccountHandler` class.""" + + def test_initialization(self): + """Test that AccountHandler initializes correctly.""" + handler = AccountHandler() + + assert isinstance(handler.logger, logging.Logger) + assert handler.logger.name == "AccountHandler" + + @pytest.mark.asyncio + async def test_handle_account_linked_event(self, bot): + """Test handle method routes ACCOUNT_LINKED event correctly.""" + handler = AccountHandler() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"discord_id": 123456789, "account_id": 987654321}, + traits={}, + ) + + with patch.object( + handler, "handle_account_linked", new_callable=AsyncMock + ) as mock_handle: + await handler.handle(body, bot) + mock_handle.assert_called_once_with(body, bot) + + @pytest.mark.asyncio + async def test_handle_account_unlinked_event(self, bot): + """Test handle method routes ACCOUNT_UNLINKED event correctly.""" + handler = AccountHandler() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_UNLINKED, + properties={"discord_id": 123456789, "account_id": 987654321}, + traits={}, + ) + + with patch.object( + handler, "handle_account_unlinked", new_callable=AsyncMock + ) as mock_handle: + await handler.handle(body, bot) + mock_handle.assert_called_once_with(body, bot) + + @pytest.mark.asyncio + async def test_handle_account_deleted_event(self, bot): + """Test handle method with ACCOUNT_DELETED event (method not implemented).""" + handler = AccountHandler() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_DELETED, + properties={"discord_id": 123456789, "account_id": 987654321}, + traits={}, + ) + + # The handle_account_deleted method is not implemented, so this should raise AttributeError + with pytest.raises(AttributeError): + await handler.handle(body, bot) + + @pytest.mark.asyncio + async def test_handle_unknown_event(self, bot): + """Test handle method with unknown event does nothing.""" + handler = AccountHandler() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.CERTIFICATE_AWARDED, # Not handled by AccountHandler + properties={"discord_id": 123456789, "account_id": 987654321}, + traits={}, + ) + + # Should not raise any exceptions, just do nothing + await handler.handle(body, bot) + + @pytest.mark.asyncio + async def test_handle_account_linked_success(self, bot): + """Test successful account linking.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + mock_member = helpers.MockMember(id=discord_id, mention="@testuser") + + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"discord_id": discord_id, "account_id": account_id}, + traits={"htb_user_id": 555}, + ) + + # Create a custom bot mock without spec_set restrictions for this test + custom_bot = MagicMock() + custom_bot.send_message = AsyncMock() + + with ( + patch.object( + handler, "validate_discord_id", return_value=discord_id + ) as mock_validate_discord, + patch.object( + handler, "validate_account_id", return_value=account_id + ) as mock_validate_account, + patch.object( + handler, + "get_guild_member", + new_callable=AsyncMock, + return_value=mock_member, + ) as mock_get_member, + patch.object( + handler, + "merge_properties_and_traits", + return_value={ + "discord_id": discord_id, + "account_id": account_id, + "htb_user_id": 555, + }, + ) as mock_merge, + patch( + "src.webhooks.handlers.account.process_account_identification", + new_callable=AsyncMock, + ) as mock_process, + patch("src.webhooks.handlers.account.settings") as mock_settings, + patch.object(handler.logger, "info") as mock_log, + ): + mock_settings.channels.VERIFY_LOGS = 12345 + + await handler.handle_account_linked(body, custom_bot) + + # Verify all method calls + mock_validate_discord.assert_called_once_with(discord_id) + mock_validate_account.assert_called_once_with(account_id) + mock_get_member.assert_called_once_with(discord_id, custom_bot) + mock_merge.assert_called_once_with(body.properties, body.traits) + mock_process.assert_called_once_with( + mock_member, + custom_bot, + traits={ + "discord_id": discord_id, + "account_id": account_id, + "htb_user_id": 555, + }, + ) + custom_bot.send_message.assert_called_once_with( + 12345, f"Account linked: {account_id} -> (@testuser ({discord_id})" + ) + mock_log.assert_called_once_with( + f"Account {account_id} linked to {discord_id}", + extra={"account_id": account_id, "discord_id": discord_id}, + ) + + @pytest.mark.asyncio + async def test_handle_account_linked_invalid_discord_id(self, bot): + """Test account linking with invalid Discord ID.""" + handler = AccountHandler() + + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"discord_id": None, "account_id": 987654321}, + traits={}, + ) + + with patch.object( + handler, + "validate_discord_id", + side_effect=HTTPException(status_code=400, detail="Invalid Discord ID"), + ): + with pytest.raises(HTTPException) as exc_info: + await handler.handle_account_linked(body, bot) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid Discord ID" + + @pytest.mark.asyncio + async def test_handle_account_linked_invalid_account_id(self, bot): + """Test account linking with invalid Account ID.""" + handler = AccountHandler() + + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"discord_id": 123456789, "account_id": None}, + traits={}, + ) + + with ( + patch.object(handler, "validate_discord_id", return_value=123456789), + patch.object( + handler, + "validate_account_id", + side_effect=HTTPException(status_code=400, detail="Invalid Account ID"), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await handler.handle_account_linked(body, bot) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid Account ID" + + @pytest.mark.asyncio + async def test_handle_account_linked_user_not_in_guild(self, bot): + """Test account linking when user is not in the Discord guild.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"discord_id": discord_id, "account_id": account_id}, + traits={}, + ) + + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object( + handler, + "get_guild_member", + new_callable=AsyncMock, + side_effect=HTTPException( + status_code=400, detail="User is not in the Discord server" + ), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await handler.handle_account_linked(body, bot) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "User is not in the Discord server" + + @pytest.mark.asyncio + async def test_handle_account_unlinked_success(self, bot): + """Test successful account unlinking.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + mock_member = helpers.MockMember(id=discord_id) + + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_UNLINKED, + properties={"discord_id": discord_id, "account_id": account_id}, + traits={}, + ) + + with ( + patch.object( + handler, "validate_discord_id", return_value=discord_id + ) as mock_validate_discord, + patch.object( + handler, "validate_account_id", return_value=account_id + ) as mock_validate_account, + patch.object( + handler, + "get_guild_member", + new_callable=AsyncMock, + return_value=mock_member, + ) as mock_get_member, + patch("src.webhooks.handlers.account.settings") as mock_settings, + ): + mock_settings.roles.VERIFIED = helpers.MockRole(id=99999, name="Verified") + mock_member.remove_roles = AsyncMock() + + await handler.handle_account_unlinked(body, bot) + + # Verify all method calls + mock_validate_discord.assert_called_once_with(discord_id) + mock_validate_account.assert_called_once_with(account_id) + mock_get_member.assert_called_once_with(discord_id, bot) + mock_member.remove_roles.assert_called_once_with( + mock_settings.roles.VERIFIED, atomic=True + ) + + @pytest.mark.asyncio + async def test_handle_account_unlinked_invalid_discord_id(self, bot): + """Test account unlinking with invalid Discord ID.""" + handler = AccountHandler() + + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_UNLINKED, + properties={"discord_id": None, "account_id": 987654321}, + traits={}, + ) + + with patch.object( + handler, + "validate_discord_id", + side_effect=HTTPException(status_code=400, detail="Invalid Discord ID"), + ): + with pytest.raises(HTTPException) as exc_info: + await handler.handle_account_unlinked(body, bot) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid Discord ID" + + @pytest.mark.asyncio + async def test_handle_account_unlinked_invalid_account_id(self, bot): + """Test account unlinking with invalid Account ID.""" + handler = AccountHandler() + + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_UNLINKED, + properties={"discord_id": 123456789, "account_id": None}, + traits={}, + ) + + with ( + patch.object(handler, "validate_discord_id", return_value=123456789), + patch.object( + handler, + "validate_account_id", + side_effect=HTTPException(status_code=400, detail="Invalid Account ID"), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await handler.handle_account_unlinked(body, bot) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid Account ID" + + @pytest.mark.asyncio + async def test_handle_account_unlinked_user_not_in_guild(self, bot): + """Test account unlinking when user is not in the Discord guild.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_UNLINKED, + properties={"discord_id": discord_id, "account_id": account_id}, + traits={}, + ) + + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object( + handler, + "get_guild_member", + new_callable=AsyncMock, + side_effect=HTTPException( + status_code=400, detail="User is not in the Discord server" + ), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await handler.handle_account_unlinked(body, bot) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "User is not in the Discord server" diff --git a/tests/src/webhooks/handlers/test_base.py b/tests/src/webhooks/handlers/test_base.py new file mode 100644 index 0000000..ba774cf --- /dev/null +++ b/tests/src/webhooks/handlers/test_base.py @@ -0,0 +1,315 @@ +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from discord import Bot +from discord.errors import NotFound +from fastapi import HTTPException + +from src.webhooks.handlers.base import BaseHandler +from src.webhooks.types import WebhookBody, Platform, WebhookEvent +from tests import helpers + + +class ConcreteHandler(BaseHandler): + """Concrete implementation of BaseHandler for testing purposes.""" + + async def handle(self, body: WebhookBody, bot: Bot) -> dict: + return {"status": "handled"} + + +class TestBaseHandler: + """Test the `BaseHandler` class.""" + + def test_initialization(self): + """Test that BaseHandler initializes correctly.""" + handler = ConcreteHandler() + + assert isinstance(handler.logger, logging.Logger) + assert handler.logger.name == "ConcreteHandler" + + def test_constants(self): + """Test that all required constants are defined.""" + handler = ConcreteHandler() + + assert handler.ACADEMY_USER_ID == "academy_user_id" + assert handler.MP_USER_ID == "mp_user_id" + assert handler.EP_USER_ID == "ep_user_id" + assert handler.CTF_USER_ID == "ctf_user_id" + assert handler.ACCOUNT_ID == "account_id" + assert handler.DISCORD_ID == "discord_id" + + @pytest.mark.asyncio + async def test_get_guild_member_success(self, bot): + """Test successful guild member retrieval.""" + handler = ConcreteHandler() + discord_id = 123456789 + mock_guild = helpers.MockGuild(id=12345) + mock_member = helpers.MockMember(id=discord_id) + + bot.fetch_guild = AsyncMock(return_value=mock_guild) + mock_guild.fetch_member = AsyncMock(return_value=mock_member) + + with patch("src.webhooks.handlers.base.settings") as mock_settings: + mock_settings.guild_ids = [12345] + + result = await handler.get_guild_member(discord_id, bot) + + assert result == mock_member + bot.fetch_guild.assert_called_once_with(12345) + mock_guild.fetch_member.assert_called_once_with(discord_id) + + @pytest.mark.asyncio + async def test_get_guild_member_not_found(self, bot): + """Test guild member retrieval when user is not in server.""" + handler = ConcreteHandler() + discord_id = 123456789 + mock_guild = helpers.MockGuild(id=12345) + + bot.fetch_guild = AsyncMock(return_value=mock_guild) + mock_guild.fetch_member = AsyncMock( + side_effect=NotFound(MagicMock(), "User not found") + ) + + with patch("src.webhooks.handlers.base.settings") as mock_settings: + mock_settings.guild_ids = [12345] + + with pytest.raises(HTTPException) as exc_info: + await handler.get_guild_member(discord_id, bot) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "User is not in the Discord server" + + def test_validate_property_success(self): + """Test successful property validation.""" + handler = ConcreteHandler() + + result = handler.validate_property("valid_value", "test_property") + assert result == "valid_value" + + result = handler.validate_property(123, "test_number") + assert result == 123 + + def test_validate_property_none(self): + """Test property validation with None value.""" + handler = ConcreteHandler() + + with pytest.raises(HTTPException) as exc_info: + handler.validate_property(None, "test_property") + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid test_property" + + def test_validate_discord_id_success(self): + """Test successful Discord ID validation.""" + handler = ConcreteHandler() + + result = handler.validate_discord_id(123456789) + assert result == 123456789 + + result = handler.validate_discord_id("987654321") + assert result == "987654321" + + def test_validate_discord_id_none(self): + """Test Discord ID validation with None value.""" + handler = ConcreteHandler() + + with pytest.raises(HTTPException) as exc_info: + handler.validate_discord_id(None) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid Discord ID" + + def test_validate_account_id_success(self): + """Test successful Account ID validation.""" + handler = ConcreteHandler() + + result = handler.validate_account_id(123456789) + assert result == 123456789 + + result = handler.validate_account_id("987654321") + assert result == "987654321" + + def test_validate_account_id_none(self): + """Test Account ID validation with None value.""" + handler = ConcreteHandler() + + with pytest.raises(HTTPException) as exc_info: + handler.validate_account_id(None) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid Account ID" + + def test_get_property_or_trait_from_properties(self): + """Test getting value from properties.""" + handler = ConcreteHandler() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"test_key": 123}, + traits={"test_key": 456, "other_key": 789}, + ) + + result = handler.get_property_or_trait(body, "test_key") + assert result == 123 # Should prioritize properties over traits + + def test_get_property_or_trait_from_traits(self): + """Test getting value from traits when not in properties.""" + handler = ConcreteHandler() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.ACCOUNT_LINKED, + properties={}, + traits={"test_key": 456}, + ) + + result = handler.get_property_or_trait(body, "test_key") + assert result == 456 + + def test_get_property_or_trait_not_found(self): + """Test getting value when key is not found.""" + handler = ConcreteHandler() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.ACCOUNT_LINKED, + properties={}, + traits={}, + ) + + result = handler.get_property_or_trait(body, "missing_key") + assert result is None + + def test_merge_properties_and_traits_no_duplicates(self): + """Test merging properties and traits without duplicates.""" + handler = ConcreteHandler() + properties = {"key1": 1, "key2": 2} + traits = {"key3": 3, "key4": 4} + + result = handler.merge_properties_and_traits(properties, traits) + + expected = {"key1": 1, "key2": 2, "key3": 3, "key4": 4} + assert result == expected + + def test_merge_properties_and_traits_with_duplicates(self): + """Test merging properties and traits with duplicate keys.""" + handler = ConcreteHandler() + properties = {"key1": 1, "key2": 2} + traits = {"key2": 99, "key3": 3} # key2 is duplicate + + result = handler.merge_properties_and_traits(properties, traits) + + expected = {"key1": 1, "key2": 2, "key3": 3} # Properties value should win + assert result == expected + + def test_merge_properties_and_traits_empty_properties(self): + """Test merging when properties is empty.""" + handler = ConcreteHandler() + properties = {} + traits = {"key1": 1, "key2": 2} + + result = handler.merge_properties_and_traits(properties, traits) + + assert result == traits + + def test_merge_properties_and_traits_empty_traits(self): + """Test merging when traits is empty.""" + handler = ConcreteHandler() + properties = {"key1": 1, "key2": 2} + traits = {} + + result = handler.merge_properties_and_traits(properties, traits) + + assert result == properties + + def test_get_platform_properties_all_present(self): + """Test getting platform properties when all are present.""" + handler = ConcreteHandler() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.ACCOUNT_LINKED, + properties={ + "account_id": 1, + "mp_user_id": 2, + "ep_user_id": 3, + "ctf_user_id": 4, + "academy_user_id": 5, + }, + traits={}, + ) + + result = handler.get_platform_properties(body) + + expected = { + "account_id": 1, + "mp_user_id": 2, + "ep_user_id": 3, + "ctf_user_id": 4, + "academy_user_id": 5, + } + assert result == expected + + def test_get_platform_properties_mixed_sources(self): + """Test getting platform properties from both properties and traits.""" + handler = ConcreteHandler() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"account_id": 1, "mp_user_id": 2}, + traits={"ep_user_id": 3, "ctf_user_id": 4, "academy_user_id": 5}, + ) + + result = handler.get_platform_properties(body) + + expected = { + "account_id": 1, + "mp_user_id": 2, + "ep_user_id": 3, + "ctf_user_id": 4, + "academy_user_id": 5, + } + assert result == expected + + def test_get_platform_properties_missing_values(self): + """Test getting platform properties when some are missing.""" + handler = ConcreteHandler() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"account_id": 1}, + traits={"mp_user_id": 2}, + ) + + result = handler.get_platform_properties(body) + + expected = { + "account_id": 1, + "mp_user_id": 2, + "ep_user_id": None, + "ctf_user_id": None, + "academy_user_id": None, + } + assert result == expected + + def test_get_platform_properties_properties_override_traits(self): + """Test that properties override traits for the same key.""" + handler = ConcreteHandler() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.ACCOUNT_LINKED, + properties={"account_id": 1, "mp_user_id": 2}, + traits={ + "mp_user_id": 999, # Should be overridden + "ep_user_id": 3, + }, + ) + + result = handler.get_platform_properties(body) + + expected = { + "account_id": 1, + "mp_user_id": 2, # Properties value should win + "ep_user_id": 3, + "ctf_user_id": None, + "academy_user_id": None, + } + assert result == expected diff --git a/tests/src/webhooks/test_handlers_init.py b/tests/src/webhooks/test_handlers_init.py new file mode 100644 index 0000000..ce0b436 --- /dev/null +++ b/tests/src/webhooks/test_handlers_init.py @@ -0,0 +1,35 @@ +from unittest import mock +from typing import Callable + +import pytest + +from src.webhooks.handlers import handlers, can_handle, handle +from src.webhooks.types import Platform, WebhookBody, WebhookEvent +from tests.conftest import bot + +class TestHandlersInit: + def test_handler_init(self): + assert handlers is not None + assert isinstance(handlers, dict) + assert len(handlers) > 0 + assert all(isinstance(handler, Callable) for handler in handlers.values()) + + def test_can_handle_unknown_platform(self): + assert not can_handle("UNKNOWN") + + def test_can_handle_success(self): + with mock.patch("src.webhooks.handlers.handlers", {Platform.MAIN: lambda x, y: True}): + assert can_handle(Platform.MAIN) + + def test_handle_success(self): + with mock.patch("src.webhooks.handlers.handlers", {Platform.MAIN: lambda x, y: 1337}): + assert handle(WebhookBody(platform=Platform.MAIN, event=WebhookEvent.ACCOUNT_LINKED, properties={}, traits={}), bot) == 1337 + + def test_handle_unknown_platform(self): + with pytest.raises(ValueError): + handle(WebhookBody(platform="UNKNOWN", event=WebhookEvent.ACCOUNT_LINKED, properties={}, traits={}), bot) + + def test_handle_unknown_event(self): + with mock.patch("src.webhooks.handlers.handlers", {Platform.MAIN: lambda x, y: 1337}): + with pytest.raises(ValueError): + handle(WebhookBody(platform=Platform.MAIN, event="UNKNOWN", properties={}, traits={}), bot) \ No newline at end of file From 39701b670301feda830f9db0f20c6053f9d99f8a Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 30 Jun 2025 16:32:42 -0400 Subject: [PATCH 16/29] Logic and helpers for handling bans --- src/helpers/ban.py | 387 ++++++++++++++++++++++++++----- src/helpers/verification.py | 18 +- src/webhooks/handlers/account.py | 67 +++++- src/webhooks/handlers/base.py | 21 +- 4 files changed, 411 insertions(+), 82 deletions(-) diff --git a/src/helpers/ban.py b/src/helpers/ban.py index 08a6895..bc31388 100644 --- a/src/helpers/ban.py +++ b/src/helpers/ban.py @@ -1,13 +1,21 @@ """Helper methods to handle bans, mutes and infractions. Bot or message responses are NOT allowed.""" + import asyncio import logging from datetime import datetime, timezone from enum import Enum - -import arrow import discord -from discord import Forbidden, Guild, HTTPException, Member, NotFound, User +from discord import ( + Forbidden, + Guild, + HTTPException, + Member, + NotFound, + User, + GuildChannel, + TextChannel, +) from sqlalchemy import select from sqlalchemy.exc import NoResultFound @@ -30,35 +38,64 @@ class BanCodes(Enum): FAILED = "FAILED" -async def _check_member(bot: Bot, guild: Guild, member: Member | User, author: Member = None) -> SimpleResponse | None: +async def _check_member( + bot: Bot, guild: Guild, member: Member | User, author: Member = None +) -> SimpleResponse | None: if isinstance(member, Member): if member_is_staff(member): - return SimpleResponse(message="You cannot ban another staff member.", delete_after=None) + return SimpleResponse( + message="You cannot ban another staff member.", delete_after=None + ) elif isinstance(member, User): member = await bot.get_member_or_user(guild, member.id) if member.bot: - return SimpleResponse(message="You cannot ban a bot.", delete_after=None, code=BanCodes.FAILED) + return SimpleResponse( + message="You cannot ban a bot.", delete_after=None, code=BanCodes.FAILED + ) if author and author.id == member.id: - return SimpleResponse(message="You cannot ban yourself.", delete_after=None, code=BanCodes.FAILED) + return SimpleResponse( + message="You cannot ban yourself.", delete_after=None, code=BanCodes.FAILED + ) -async def _get_ban_or_create(member: Member, ban: Ban, infraction: Infraction) -> tuple[int, bool]: +async def get_ban(member: Member) -> Ban | None: async with AsyncSessionLocal() as session: - stmt = select(Ban).filter(Ban.user_id == member.id, Ban.unbanned.is_(False)).limit(1) + stmt = ( + select(Ban) + .filter(Ban.user_id == member.id, Ban.unbanned.is_(False)) + .limit(1) + ) result = await session.scalars(stmt) - existing_ban = result.first() - if existing_ban: - return existing_ban.id, True + return result.first() + +async def update_ban(ban: Ban) -> None: + logger.info(f"Updating ban {ban.id} for user {ban.user_id} with expiration {ban.unban_time}") + async with AsyncSessionLocal() as session: + session.add(ban) + await session.commit() + + +async def _get_ban_or_create( + member: Member, ban: Ban, infraction: Infraction +) -> tuple[int, bool]: + existing_ban = await get_ban(member) + if existing_ban: + return existing_ban.id, True + + async with AsyncSessionLocal() as session: session.add(ban) session.add(infraction) await session.commit() + ban_id: int = ban.id assert ban_id is not None return ban_id, False -async def _create_ban_response(member: Member | User, end_date: str, dm_banned_member: bool, needs_approval: bool) -> SimpleResponse: +async def _create_ban_response( + member: Member | User, end_date: str, dm_banned_member: bool, needs_approval: bool +) -> SimpleResponse: """Create a SimpleResponse for ban operations.""" if needs_approval: if member: @@ -77,15 +114,151 @@ async def _create_ban_response(member: Member | User, end_date: str, dm_banned_m return SimpleResponse( message=message, delete_after=0 if not needs_approval else None, - code=BanCodes.SUCCESS + code=BanCodes.SUCCESS, ) -async def ban_member( - bot: Bot, guild: Guild, member: Member | User, duration: str, reason: str, evidence: str, author: Member = None, - needs_approval: bool = True +async def _send_ban_notice( + guild: Guild, + member: Member, + reason: str, + author: str, + end_date: str, + channel: TextChannel | None, +) -> None: + """Send a ban log to the moderator channel.""" + if not isinstance(channel, TextChannel): + channel = guild.get_channel(settings.channels.SR_MOD) + + embed = discord.Embed( + title="Ban", + description=f"User {member.mention} ({member.id}) was banned on the platform and thus banned here.", + color=0xFF2429, + ) + embed.add_field(name="Reason", value=reason) + embed.add_field(name="Author", value=author) + embed.add_field(name="End Date", value=end_date) + + await channel.send(embed=embed) + + +async def handle_platform_ban_or_update( + bot: Bot, + guild: Guild, + member: Member, + expires_timestamp: int, + reason: str, + evidence: str, + author_name: str, + expires_at_str: str, + log_channel_id: int, + logger, + extra_log_data: dict = None, +) -> dict: + """Handle platform ban by either creating new ban, updating existing ban, or taking no action. + + Args: + bot: The Discord bot instance + guild: The guild to ban the member from + member: The member to ban + expires_timestamp: Unix timestamp when the ban should end + reason: Reason for the ban + evidence: Evidence supporting the ban (notes) + author_name: Name of the person who created the ban + expires_at_str: Human-readable expiration date string + log_channel_id: Channel ID for logging ban actions + logger: Logger instance for recording events + extra_log_data: Additional data to include in log entries + + Returns: + dict with 'action' key indicating what was done: 'unbanned', 'extended', 'no_action', 'updated', 'created' + """ + if extra_log_data is None: + extra_log_data = {} + + expires_dt = datetime.fromtimestamp(expires_timestamp) + + existing_ban = await get_ban(member) + if not existing_ban: + # No existing ban, create new one + await ban_member_with_epoch( + bot, guild, member, expires_timestamp, reason, evidence, needs_approval=False + ) + await _send_ban_notice( + guild, member, reason, author_name, expires_at_str, guild.get_channel(log_channel_id) + ) + logger.info(f"Created new platform ban for user {member.id} until {expires_at_str}", extra=extra_log_data) + return {"action": "created"} + + # Existing ban found - determine what to do based on ban type and timing + is_platform_ban = existing_ban.reason.startswith("Platform Ban") + + if is_platform_ban: + # Platform bans have authority over other platform bans + if expires_dt < datetime.now(): + # Platform ban has expired, unban the user + await unban_member(guild, member) + msg = f"User {member.mention} ({member.id}) has been unbanned due to platform ban expiration." + await guild.get_channel(log_channel_id).send(msg) + logger.info(msg, extra=extra_log_data) + return {"action": "unbanned"} + + if existing_ban.unban_time < expires_timestamp: + # Extend the existing platform ban + existing_ban.unban_time = expires_timestamp + await update_ban(existing_ban) + msg = f"User {member.mention} ({member.id}) has had their ban extended to {expires_at_str}." + await guild.get_channel(log_channel_id).send(msg) + logger.info(msg, extra=extra_log_data) + return {"action": "extended"} + else: + # Non-platform ban exists + if existing_ban.unban_time >= expires_timestamp: + # Existing ban is longer than platform ban, no action needed + logger.info( + f"User {member.mention} ({member.id}) is already banned until {existing_ban.unban_time}, " + f"which exceeds or equals the platform ban expiration date {expires_at_str}. No action taken.", + extra=extra_log_data, + ) + return {"action": "no_action"} + else: + # Platform ban is longer, update the existing ban + existing_ban.unban_time = expires_timestamp + existing_ban.reason = f"Platform Ban: {reason}" # Update reason to indicate platform authority + await update_ban(existing_ban) + logger.info(f"Updated existing ban for user {member.id} until {expires_at_str}.", extra=extra_log_data) + return {"action": "updated"} + + # Default case (shouldn't reach here, but for safety) + logger.warning(f"Unexpected case in platform ban handling for user {member.id}", extra=extra_log_data) + return {"action": "no_action"} + + +async def ban_member_with_epoch( + bot: Bot, + guild: Guild, + member: Member | User, + unban_epoch_time: int, + reason: str, + evidence: str, + author: Member = None, + needs_approval: bool = True, ) -> SimpleResponse | None: - """Ban a member from the guild.""" + """Ban a member from the guild until a specific epoch time. + + Args: + bot: The Discord bot instance + guild: The guild to ban the member from + member: The member or user to ban + unban_epoch_time: Unix timestamp when the ban should end + reason: Reason for the ban + evidence: Evidence supporting the ban + author: The member issuing the ban (defaults to bot user) + needs_approval: Whether the ban requires approval + + Returns: + SimpleResponse with the result of the ban operation, or None if no response needed + """ if checked := await _check_member(bot, guild, member, author): return checked @@ -96,36 +269,41 @@ async def ban_member( if not evidence: evidence = "none provided" - # Validate duration - dur, dur_exc = validate_duration(duration) - # Check if duration is valid, - # negative values are generally not allowed, - # so they should be caught here - if dur <= 0: - return SimpleResponse(message=dur_exc, delete_after=15) - else: - end_date: str = datetime.fromtimestamp(dur, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + # Validate epoch time is in the future + current_time = datetime.now(tz=timezone.utc).timestamp() + if unban_epoch_time <= current_time: + return SimpleResponse( + message="Unban time must be in the future", + delete_after=15 + ) + + end_date: str = datetime.fromtimestamp(unban_epoch_time, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) if author is None: author = bot.user ban = Ban( - user_id=member.id, reason=reason, moderator_id=author.id, unban_time=dur, - approved=False if needs_approval else True + user_id=member.id, + reason=reason, + moderator_id=author.id, + unban_time=unban_epoch_time, + approved=False if needs_approval else True, ) infraction = Infraction( user_id=member.id, reason=f"Previously banned for: {reason} - Evidence: {evidence}", weight=0, moderator_id=author.id, - date=datetime.now().date() + date=datetime.now().date(), ) ban_id, is_existing = await _get_ban_or_create(member, ban, infraction) if is_existing: return SimpleResponse( message=f"A ban with id: {ban_id} already exists for member {member}", delete_after=None, - code=BanCodes.ALREADY_EXISTS + code=BanCodes.ALREADY_EXISTS, ) # DM member, before we ban, else we cannot dm since we do not share a guild @@ -135,19 +313,26 @@ async def ban_member( await guild.ban(member, reason=reason, delete_message_seconds=0) except Forbidden as exc: logger.warning( - "Ban failed due to permission error", exc_info=exc, - extra={"ban_requestor": author.name, "ban_receiver": member.id} + "Ban failed due to permission error", + exc_info=exc, + extra={"ban_requestor": author.name, "ban_receiver": member.id}, ) if author: - return SimpleResponse(message="You do not have the proper permissions to ban.", delete_after=None, code=BanCodes.FAILED) + return SimpleResponse( + message="You do not have the proper permissions to ban.", + delete_after=None, + code=BanCodes.FAILED, + ) return except HTTPException as ex: - logger.warning(f"HTTPException when trying to ban user with ID {member.id}", exc_info=ex) + logger.warning( + f"HTTPException when trying to ban user with ID {member.id}", exc_info=ex + ) if author: return SimpleResponse( message="Here's a 400 Bad Request for you. Just like when you tried to ask me out, last week.", delete_after=None, - code=BanCodes.FAILED + code=BanCodes.FAILED, ) return @@ -155,32 +340,93 @@ async def ban_member( if not needs_approval: logger.info( "Member has been banned permanently.", - extra={"ban_requestor": author.name, "ban_receiver": member.id, "dm_banned_member": dm_banned_member} + extra={ + "ban_requestor": author.name, + "ban_receiver": member.id, + "dm_banned_member": dm_banned_member, + }, ) - unban_task = schedule(unban_member(guild, member), run_at=datetime.fromtimestamp(ban.unban_time)) + unban_task = schedule( + unban_member(guild, member), run_at=datetime.fromtimestamp(ban.unban_time) + ) asyncio.create_task(unban_task) - logger.debug("Unbanned sceduled for ban", extra={"ban_id": ban_id, "unban_time": ban.unban_time}) + logger.debug( + "Unbanned sceduled for ban", + extra={"ban_id": ban_id, "unban_time": ban.unban_time}, + ) else: member_name = f"{member.display_name} ({member.name})" embed = discord.Embed( title=f"Ban request #{ban_id}", description=f"{author.display_name} ({author.name}) " - f"would like to ban {member_name} until {end_date} (UTC).\n" - f"Reason: {reason}\n" - f"Evidence: {evidence}", ) + f"would like to ban {member_name} until {end_date} (UTC).\n" + f"Reason: {reason}\n" + f"Evidence: {evidence}", + ) embed.set_thumbnail(url=f"{settings.HTB_URL}/images/logo600.png") view = BanDecisionView(ban_id, bot, guild, member, end_date, reason) await guild.get_channel(settings.channels.SR_MOD).send(embed=embed, view=view) - return await _create_ban_response(member, end_date, dm_banned_member, needs_approval) + return await _create_ban_response( + member, end_date, dm_banned_member, needs_approval + ) + +async def ban_member( + bot: Bot, + guild: Guild, + member: Member | User, + duration: str | int, + reason: str, + evidence: str, + author: Member = None, + needs_approval: bool = True, +) -> SimpleResponse | None: + """Ban a member from the guild using a duration. + + Args: + bot: The Discord bot instance + guild: The guild to ban the member from + member: The member or user to ban + duration: Duration string (e.g., "1d", "1h") or seconds as int + reason: Reason for the ban + evidence: Evidence supporting the ban + author: The member issuing the ban (defaults to bot user) + needs_approval: Whether the ban requires approval + + Returns: + SimpleResponse with the result of the ban operation, or None if no response needed + """ + dur, dur_exc = validate_duration(duration) -async def _dm_banned_member(end_date: str, guild: Guild, member: Member, reason: str) -> bool: + # Check if duration is valid, + # negative values are generally not allowed, + # so they should be caught here + if dur <= 0: + return SimpleResponse(message=dur_exc, delete_after=15) + + return await ban_member_with_epoch( + bot=bot, + guild=guild, + member=member, + unban_epoch_time=dur, + reason=reason, + evidence=evidence, + author=author, + needs_approval=needs_approval, + ) + + +async def _dm_banned_member( + end_date: str, guild: Guild, member: Member, reason: str +) -> bool: """Send a message to the member about the ban.""" - message = (f"You have been banned from {guild.name} until {end_date} (UTC). " - f"To appeal the ban, please reach out to an Administrator.\n" - f"Following is the reason given:\n>>> {reason}\n") + message = ( + f"You have been banned from {guild.name} until {end_date} (UTC). " + f"To appeal the ban, please reach out to an Administrator.\n" + f"Following is the reason given:\n>>> {reason}\n" + ) try: await member.send(message) return True @@ -188,10 +434,12 @@ async def _dm_banned_member(end_date: str, guild: Guild, member: Member, reason: logger.warning( f"Could not DM member with id {member.id} due to privacy settings, however will still attempt to ban " f"them...", - exc_info=ex + exc_info=ex, ) except HTTPException as ex: - logger.warning(f"HTTPException when trying to unban user with ID {member.id}", exc_info=ex) + logger.warning( + f"HTTPException when trying to unban user with ID {member.id}", exc_info=ex + ) return False @@ -201,16 +449,28 @@ async def unban_member(guild: Guild, member: Member) -> Member: await guild.unban(member) logger.info(f"Unbanned user {member.id}.") except Forbidden as ex: - logger.error(f"Permission denied when trying to unban user with ID {member.id}", exc_info=ex) + logger.error( + f"Permission denied when trying to unban user with ID {member.id}", + exc_info=ex, + ) except NotFound as ex: logger.error( f"NotFound when trying to unban user with ID {member.id}. " - f"This could indicate that the user is not currently banned.", exc_info=ex, ) + f"This could indicate that the user is not currently banned.", + exc_info=ex, + ) except HTTPException as ex: - logger.error(f"HTTPException when trying to unban user with ID {member.id}", exc_info=ex) + logger.error( + f"HTTPException when trying to unban user with ID {member.id}", exc_info=ex + ) async with AsyncSessionLocal() as session: - stmt = select(Ban).filter(Ban.user_id == member.id).filter(Ban.unbanned.is_(False)).limit(1) + stmt = ( + select(Ban) + .filter(Ban.user_id == member.id) + .filter(Ban.unbanned.is_(False)) + .limit(1) + ) result = await session.scalars(stmt) ban = result.first() if ban: @@ -224,7 +484,12 @@ async def unban_member(guild: Guild, member: Member) -> Member: async def mute_member( - bot: Bot, guild: Guild, member: Member, duration: str, reason: str, author: Member = None + bot: Bot, + guild: Guild, + member: Member, + duration: str, + reason: str, + author: Member = None, ) -> SimpleResponse | None: """Mute a member on the guild.""" if checked := await _check_member(bot, guild, member, author): @@ -289,7 +554,9 @@ async def add_infraction( if len(reason) == 0: reason = "No reason given ..." - infraction = Infraction(user_id=member.id, reason=reason, weight=weight, moderator_id=author.id) + infraction = Infraction( + user_id=member.id, reason=reason, weight=weight, moderator_id=author.id + ) async with AsyncSessionLocal() as session: session.add(infraction) await session.commit() @@ -304,9 +571,15 @@ async def add_infraction( ) except Forbidden as ex: message = "Could not DM member due to privacy settings, however the infraction was still added." - logger.warning(f"Forbidden, when trying to contact user with ID {member.id} about infraction.", exc_info=ex) + logger.warning( + f"Forbidden, when trying to contact user with ID {member.id} about infraction.", + exc_info=ex, + ) except HTTPException as ex: message = "Here's a 400 Bad Request for you. Just like when you tried to ask me out, last week." - logger.warning(f"HTTPException when trying to add infraction for user with ID {member.id}", exc_info=ex) + logger.warning( + f"HTTPException when trying to add infraction for user with ID {member.id}", + exc_info=ex, + ) return SimpleResponse(message=message, delete_after=None) diff --git a/src/helpers/verification.py b/src/helpers/verification.py index ae02038..9998272 100644 --- a/src/helpers/verification.py +++ b/src/helpers/verification.py @@ -3,12 +3,12 @@ import aiohttp import discord -from discord import ApplicationContext, Forbidden, Member, Role, User, HTTPException +from discord import ApplicationContext, Forbidden, HTTPException, Member, Role, User from discord.ext.commands import GuildNotFound, MemberNotFound from src.bot import Bot from src.core import settings -from src.helpers.ban import BanCodes, ban_member +from src.helpers.ban import BanCodes, ban_member, _send_ban_notice logger = logging.getLogger(__name__) @@ -188,19 +188,21 @@ async def _handle_banned_user(member: Member, bot: Bot): member, "1337w", ( - "Banned on the HTB Platform. Ban duration could not be determined. " + "Platform Ban - Ban duration could not be determined. " "Please login to confirm ban details and contact HTB Support to appeal." ), None, needs_approval=False, ) if resp.code == BanCodes.SUCCESS: - embed = discord.Embed( - title="Identification error", - description=f"User {member.mention} ({member.id}) was platform banned HTB and thus also here.", - color=0xFF2429, + await _send_ban_notice( + member.guild, + member, + resp.message, + "System", + "1337w", + member.guild.get_channel(settings.channels.VERIFY_LOGS), ) - await member.guild.get_channel(settings.channels.VERIFY_LOGS).send(embed=embed) async def _set_nickname(member: Member, nickname: str) -> bool: diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 895cc9f..3ba5b07 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -1,13 +1,11 @@ -import logging - +from datetime import datetime from discord import Bot from src.core import settings +from src.helpers.ban import handle_platform_ban_or_update from src.helpers.verification import process_account_identification -from src.webhooks.types import WebhookBody, WebhookEvent - from src.webhooks.handlers.base import BaseHandler - +from src.webhooks.types import WebhookBody, WebhookEvent class AccountHandler(BaseHandler): @@ -33,10 +31,20 @@ async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: account_id = self.validate_account_id(body.properties.get("account_id")) member = await self.get_guild_member(discord_id, bot) - await process_account_identification(member, bot, traits=self.merge_properties_and_traits(body.properties, body.traits)) - await bot.send_message(settings.channels.VERIFY_LOGS, f"Account linked: {account_id} -> ({member.mention} ({member.id})") + await process_account_identification( + member, + bot, + traits=self.merge_properties_and_traits(body.properties, body.traits), + ) + await bot.send_message( + settings.channels.VERIFY_LOGS, + f"Account linked: {account_id} -> ({member.mention} ({member.id})", + ) - self.logger.info(f"Account {account_id} linked to {member.id}", extra={"account_id": account_id, "discord_id": discord_id}) + self.logger.info( + f"Account {account_id} linked to {member.id}", + extra={"account_id": account_id, "discord_id": discord_id}, + ) async def handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: """ @@ -46,5 +54,46 @@ async def handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: account_id = self.validate_account_id(body.properties.get("account_id")) member = await self.get_guild_member(discord_id, bot) - + await member.remove_roles(settings.roles.VERIFIED, atomic=True) + + async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the account banned event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + account_id = self.validate_account_id(body.properties.get("account_id")) + expires_at = self.validate_property( + body.properties.get("expires_at"), "expires_at" + ) + reason = body.properties.get("reason") + notes = body.properties.get("notes") + created_by = body.properties.get("created_by") + + expires_ts = int(datetime.fromisoformat(expires_at).timestamp()) + extra = {"account_id": account_id, "discord_id": discord_id} + + member = await self.get_guild_member(discord_id, bot) + if not member: + self.logger.warning( + f"Cannot ban user {discord_id}- not found in guild", extra=extra + ) + return + + # Use the generic ban helper to handle all the complex logic + result = await handle_platform_ban_or_update( + bot=bot, + guild=bot.guild, + member=member, + expires_timestamp=expires_ts, + reason=f"Platform Ban - {reason}", + evidence=notes or "N/A", + author_name=created_by or "System", + expires_at_str=expires_at, + log_channel_id=settings.channels.BOT_LOGS, + logger=self.logger, + extra_log_data=extra, + ) + + self.logger.debug(f"Platform ban handling result: {result['action']}", extra=extra) + diff --git a/src/webhooks/handlers/base.py b/src/webhooks/handlers/base.py index cfb63d9..9a5be09 100644 --- a/src/webhooks/handlers/base.py +++ b/src/webhooks/handlers/base.py @@ -1,5 +1,4 @@ import logging - from abc import ABC, abstractmethod from typing import TypeVar @@ -17,11 +16,10 @@ class BaseHandler(ABC): ACADEMY_USER_ID = "academy_user_id" MP_USER_ID = "mp_user_id" EP_USER_ID = "ep_user_id" - CTF_USER_ID = "ctf_user_id" + CTF_USER_ID = "ctf_user_id" ACCOUNT_ID = "account_id" DISCORD_ID = "discord_id" - def __init__(self): self.logger = logging.getLogger(self.__class__.__name__) @@ -92,13 +90,18 @@ def get_property_or_trait(self, body: WebhookBody, name: str) -> int | None: Gets a trait or property from the webhook body. """ return body.properties.get(name) or body.traits.get(name) - - def merge_properties_and_traits(self, properties: dict[str, int | None], traits: dict[str, int | None]) -> dict[str, int | None]: + + def merge_properties_and_traits( + self, properties: dict[str, int | None], traits: dict[str, int | None] + ) -> dict[str, int | None]: """ Merges the properties and traits from the webhook body without duplicates. If a property and trait have the same name but different values, the property value will be used. """ - return {**properties, **{k: v for k, v in traits.items() if k not in properties}} + return { + **properties, + **{k: v for k, v in traits.items() if k not in properties}, + } def get_platform_properties(self, body: WebhookBody) -> dict[str, int | None]: """ @@ -109,6 +112,8 @@ def get_platform_properties(self, body: WebhookBody) -> dict[str, int | None]: self.MP_USER_ID: self.get_property_or_trait(body, self.MP_USER_ID), self.EP_USER_ID: self.get_property_or_trait(body, self.EP_USER_ID), self.CTF_USER_ID: self.get_property_or_trait(body, self.CTF_USER_ID), - self.ACADEMY_USER_ID: self.get_property_or_trait(body, self.ACADEMY_USER_ID), + self.ACADEMY_USER_ID: self.get_property_or_trait( + body, self.ACADEMY_USER_ID + ), } - return properties \ No newline at end of file + return properties From e4c71d59c37744281ac55b1b4e7f93c496034c83 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 30 Jun 2025 17:26:57 -0400 Subject: [PATCH 17/29] =?UTF-8?q?=E2=9C=A8=20Event:=20Account=20Deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/account.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 3ba5b07..d643248 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -22,6 +22,8 @@ async def handle(self, body: WebhookBody, bot: Bot) -> dict: await self.handle_account_unlinked(body, bot) elif body.event == WebhookEvent.ACCOUNT_DELETED: await self.handle_account_deleted(body, bot) + elif body.event == WebhookEvent.ACCOUNT_BANNED: + await self.handle_account_banned(body, bot) async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: """ @@ -95,5 +97,23 @@ async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: extra_log_data=extra, ) - self.logger.debug(f"Platform ban handling result: {result['action']}", extra=extra) + self.logger.debug( + f"Platform ban handling result: {result['action']}", extra=extra + ) + async def handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the account deleted event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + account_id = self.validate_account_id(body.properties.get("account_id")) + + member = await self.get_guild_member(discord_id, bot) + if not member: + self.logger.warning( + f"Cannot delete account {account_id}- not found in guild", + extra={"account_id": account_id, "discord_id": discord_id}, + ) + return + + await member.remove_roles(settings.roles.VERIFIED, atomic=True) From 4ec29d17e72d2fa0e71563cac7d3a92f7d8e3a76 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 30 Jun 2025 19:18:31 -0400 Subject: [PATCH 18/29] =?UTF-8?q?=F0=9F=90=9B=20Fix=20model=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webhooks/server.py b/src/webhooks/server.py index 06d02af..9ae23c1 100644 --- a/src/webhooks/server.py +++ b/src/webhooks/server.py @@ -7,7 +7,7 @@ from fastapi import FastAPI, HTTPException, Request from hypercorn.asyncio import serve as hypercorn_serve from hypercorn.config import Config as HypercornConfig -from pydantic import ValidationError +from pydantic import ValidationError, from src.bot import bot from src.core import settings @@ -66,7 +66,7 @@ async def webhook_handler(request: Request) -> Dict[str, Any]: raise HTTPException(status_code=401, detail="Unauthorized") try: - body = WebhookBody.model_validate(json.loads(body)) + body = WebhookBody.validate(json.loads(body)) except ValidationError as e: logger.warning("Invalid webhook request: %s", e.errors()) raise HTTPException(status_code=400, detail="Invalid webhook request body") From e5b0fbb693fae6e6070699249beedad9063dac5b Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 30 Jun 2025 20:17:02 -0400 Subject: [PATCH 19/29] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Fix=20linting=20&?= =?UTF-8?q?=20type=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bot.py | 4 ++ src/core/config.py | 2 + src/helpers/ban.py | 46 ++++++++++---------- src/helpers/responses.py | 8 ++-- src/helpers/verification.py | 72 ++++++++++++++++---------------- src/webhooks/handlers/account.py | 4 +- src/webhooks/types.py | 11 +++-- 7 files changed, 79 insertions(+), 68 deletions(-) diff --git a/src/bot.py b/src/bot.py index f535903..f598844 100644 --- a/src/bot.py +++ b/src/bot.py @@ -12,6 +12,7 @@ MissingRequiredArgument, NoPrivateMessage, UserInputError, ) from sqlalchemy.exc import NoResultFound +from typing import TypeVar from src import trace_config from src.core import constants, settings @@ -20,6 +21,9 @@ logger = logging.getLogger(__name__) +BOT_TYPE = TypeVar("BOT_TYPE", "Bot", DiscordBot) + + class Bot(DiscordBot): """Base bot class.""" diff --git a/src/core/config.py b/src/core/config.py index b91644d..d07de2c 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -331,6 +331,8 @@ def load_settings(env_file: str | None = None): global_settings.roles.NOOB, global_settings.roles.VIP, global_settings.roles.VIP_PLUS, + ], + "ALL_SEASON_RANKS": [ global_settings.roles.SEASON_HOLO, global_settings.roles.SEASON_PLATINUM, global_settings.roles.SEASON_RUBY, diff --git a/src/helpers/ban.py b/src/helpers/ban.py index bc31388..f414b21 100644 --- a/src/helpers/ban.py +++ b/src/helpers/ban.py @@ -13,8 +13,8 @@ Member, NotFound, User, - GuildChannel, TextChannel, + ClientUser, ) from sqlalchemy import select from sqlalchemy.exc import NoResultFound @@ -39,7 +39,7 @@ class BanCodes(Enum): async def _check_member( - bot: Bot, guild: Guild, member: Member | User, author: Member = None + bot: Bot, guild: Guild, member: Member | User, author: Member | ClientUser | None = None ) -> SimpleResponse | None: if isinstance(member, Member): if member_is_staff(member): @@ -47,7 +47,7 @@ async def _check_member( message="You cannot ban another staff member.", delete_after=None ) elif isinstance(member, User): - member = await bot.get_member_or_user(guild, member.id) + member = await bot.get_member_or_user(guild, member.id) # type: ignore if member.bot: return SimpleResponse( message="You cannot ban a bot.", delete_after=None, code=BanCodes.FAILED @@ -58,7 +58,7 @@ async def _check_member( ) -async def get_ban(member: Member) -> Ban | None: +async def get_ban(member: Member | User) -> Ban | None: async with AsyncSessionLocal() as session: stmt = ( select(Ban) @@ -77,7 +77,7 @@ async def update_ban(ban: Ban) -> None: async def _get_ban_or_create( - member: Member, ban: Ban, infraction: Infraction + member: Member | User, ban: Ban, infraction: Infraction ) -> tuple[int, bool]: existing_ban = await get_ban(member) if existing_ban: @@ -128,7 +128,7 @@ async def _send_ban_notice( ) -> None: """Send a ban log to the moderator channel.""" if not isinstance(channel, TextChannel): - channel = guild.get_channel(settings.channels.SR_MOD) + channel = guild.get_channel(settings.channels.SR_MOD) # type: ignore embed = discord.Embed( title="Ban", @@ -139,7 +139,7 @@ async def _send_ban_notice( embed.add_field(name="Author", value=author) embed.add_field(name="End Date", value=end_date) - await channel.send(embed=embed) + await channel.send(embed=embed) # type: ignore async def handle_platform_ban_or_update( @@ -153,7 +153,7 @@ async def handle_platform_ban_or_update( expires_at_str: str, log_channel_id: int, logger, - extra_log_data: dict = None, + extra_log_data: dict | None = None, ) -> dict: """Handle platform ban by either creating new ban, updating existing ban, or taking no action. @@ -185,7 +185,7 @@ async def handle_platform_ban_or_update( bot, guild, member, expires_timestamp, reason, evidence, needs_approval=False ) await _send_ban_notice( - guild, member, reason, author_name, expires_at_str, guild.get_channel(log_channel_id) + guild, member, reason, author_name, expires_at_str, guild.get_channel(log_channel_id) # type: ignore ) logger.info(f"Created new platform ban for user {member.id} until {expires_at_str}", extra=extra_log_data) return {"action": "created"} @@ -199,7 +199,7 @@ async def handle_platform_ban_or_update( # Platform ban has expired, unban the user await unban_member(guild, member) msg = f"User {member.mention} ({member.id}) has been unbanned due to platform ban expiration." - await guild.get_channel(log_channel_id).send(msg) + await guild.get_channel(log_channel_id).send(msg) # type: ignore logger.info(msg, extra=extra_log_data) return {"action": "unbanned"} @@ -208,7 +208,7 @@ async def handle_platform_ban_or_update( existing_ban.unban_time = expires_timestamp await update_ban(existing_ban) msg = f"User {member.mention} ({member.id}) has had their ban extended to {expires_at_str}." - await guild.get_channel(log_channel_id).send(msg) + await guild.get_channel(log_channel_id).send(msg) # type: ignore logger.info(msg, extra=extra_log_data) return {"action": "extended"} else: @@ -241,9 +241,9 @@ async def ban_member_with_epoch( unban_epoch_time: int, reason: str, evidence: str, - author: Member = None, + author: Member | ClientUser | None = None, needs_approval: bool = True, -) -> SimpleResponse | None: +) -> SimpleResponse: """Ban a member from the guild until a specific epoch time. Args: @@ -283,6 +283,7 @@ async def ban_member_with_epoch( if author is None: author = bot.user + assert isinstance(author, Member) # For linting ban = Ban( user_id=member.id, @@ -366,7 +367,7 @@ async def ban_member_with_epoch( ) embed.set_thumbnail(url=f"{settings.HTB_URL}/images/logo600.png") view = BanDecisionView(ban_id, bot, guild, member, end_date, reason) - await guild.get_channel(settings.channels.SR_MOD).send(embed=embed, view=view) + await guild.get_channel(settings.channels.SR_MOD).send(embed=embed, view=view) # type: ignore return await _create_ban_response( member, end_date, dm_banned_member, needs_approval @@ -377,12 +378,12 @@ async def ban_member( bot: Bot, guild: Guild, member: Member | User, - duration: str | int, + duration: str, reason: str, evidence: str, - author: Member = None, + author: Member | None = None, needs_approval: bool = True, -) -> SimpleResponse | None: +) -> SimpleResponse: """Ban a member from the guild using a duration. Args: @@ -419,7 +420,7 @@ async def ban_member( async def _dm_banned_member( - end_date: str, guild: Guild, member: Member, reason: str + end_date: str, guild: Guild, member: Member | User, reason: str ) -> bool: """Send a message to the member about the ban.""" message = ( @@ -443,7 +444,7 @@ async def _dm_banned_member( return False -async def unban_member(guild: Guild, member: Member) -> Member: +async def unban_member(guild: Guild, member: Member | User) -> Member | User: """Unban a member from the guild.""" try: await guild.unban(member) @@ -489,7 +490,7 @@ async def mute_member( member: Member, duration: str, reason: str, - author: Member = None, + author: Member | ClientUser | None = None, ) -> SimpleResponse | None: """Mute a member on the guild.""" if checked := await _check_member(bot, guild, member, author): @@ -507,13 +508,14 @@ async def mute_member( if author is None: author = bot.user + assert isinstance(author, Member) # For linting role = guild.get_role(settings.roles.MUTED) if member: # No longer on the server - cleanup, but don't attempt to remove a role logger.info(f"Add mute from {member.name}:{member.id}.") - await member.add_roles(role, reason=reason) + await member.add_roles(role, reason=reason) # type: ignore mute = Mute( user_id=member.id, reason=reason, moderator_id=author.id, unmute_time=dur @@ -530,7 +532,7 @@ async def unmute_member(guild: Guild, member: Member) -> Member: if isinstance(member, Member): # No longer on the server - cleanup, but don't attempt to remove a role logger.info(f"Remove mute from {member.name}:{member.id}.") - await member.remove_roles(role) + await member.remove_roles(role) # type: ignore await member.remove_timeout() async with AsyncSessionLocal() as session: diff --git a/src/helpers/responses.py b/src/helpers/responses.py index 91a8213..e80da1a 100644 --- a/src/helpers/responses.py +++ b/src/helpers/responses.py @@ -1,16 +1,16 @@ import json - +from typing import Any class SimpleResponse(object): """A simple response object.""" - def __init__(self, message: str, delete_after: int | None = None, code: str | None = None): + def __init__(self, message: str, delete_after: int | None = None, code: str | Any = None): self.message = message self.delete_after = delete_after self.code = code def __str__(self): - return json.dumps(dict(self), ensure_ascii=False) - + return json.dumps(dict(self), ensure_ascii=False) # type: ignore + def __repr__(self): return self.__str__() diff --git a/src/helpers/verification.py b/src/helpers/verification.py index 9998272..7b94b6b 100644 --- a/src/helpers/verification.py +++ b/src/helpers/verification.py @@ -1,12 +1,20 @@ import logging -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Any, TypeVar import aiohttp import discord -from discord import ApplicationContext, Forbidden, HTTPException, Member, Role, User +from discord import ( + ApplicationContext, + Forbidden, + HTTPException, + Member, + Role, + User, +) from discord.ext.commands import GuildNotFound, MemberNotFound -from src.bot import Bot +from src.bot import Bot, BOT_TYPE + from src.core import settings from src.helpers.ban import BanCodes, ban_member, _send_ban_notice @@ -114,12 +122,12 @@ async def get_season_rank(htb_uid: int) -> str | None: response = await r.json() elif r.status == 404: logger.error("Invalid Season ID.") - response = None + response = {} else: logger.error( f"Non-OK HTTP status code returned from identifier lookup: {r.status}." ) - response = None + response = {} if not response["data"]: rank = None @@ -132,15 +140,6 @@ async def get_season_rank(htb_uid: int) -> str | None: return rank -async def _check_for_ban(member: Member) -> Optional[Dict]: - """Check if the member is banned.""" - try: - member.guild.get_role(settings.roles.BANNED) - return True - except Forbidden: - return False - - async def process_certification(certid: str, name: str): """Process certifications.""" cert_api_url = f"{settings.API_V4_URL}/certificate/lookup" @@ -155,7 +154,7 @@ async def process_certification(certid: str, name: str): logger.error( f"Non-OK HTTP status code returned from identifier lookup: {r.status}." ) - response = None + response = {} try: certRawName = response["certificates"][0]["name"] except IndexError: @@ -175,7 +174,7 @@ async def process_certification(certid: str, name: str): return cert -async def _handle_banned_user(member: Member, bot: Bot): +async def _handle_banned_user(member: Member, bot: BOT_TYPE): """Handle banned trait during account linking. Args: @@ -183,7 +182,7 @@ async def _handle_banned_user(member: Member, bot: Bot): bot (Bot): The bot instance. """ resp = await ban_member( - bot, + bot, # type: ignore member.guild, member, "1337w", @@ -191,7 +190,8 @@ async def _handle_banned_user(member: Member, bot: Bot): "Platform Ban - Ban duration could not be determined. " "Please login to confirm ban details and contact HTB Support to appeal." ), - None, + "N/A", + None, needs_approval=False, ) if resp.code == BanCodes.SUCCESS: @@ -201,7 +201,7 @@ async def _handle_banned_user(member: Member, bot: Bot): resp.message, "System", "1337w", - member.guild.get_channel(settings.channels.VERIFY_LOGS), + member.guild.get_channel(settings.channels.VERIFY_LOGS), # type: ignore ) @@ -224,7 +224,7 @@ async def _set_nickname(member: Member, nickname: str) -> bool: async def process_account_identification( - member: Member, bot: Bot, traits: dict[str, str] | None = None + member: Member, bot: BOT_TYPE, traits: dict[str, Any] ) -> None: """Process HTB account identification, to be called during account linking. @@ -233,12 +233,14 @@ async def process_account_identification( bot (Bot): The bot instance. traits (dict[str, str] | None): Optional user traits to process. """ - await member.add_roles(member.guild.get_role(settings.roles.VERIFIED), atomic=True) + await member.add_roles(member.guild.get_role(settings.roles.VERIFIED), atomic=True) # type: ignore nickname_changed = False + traits = traits or {} + if traits.get("username") and traits.get("username") != member.name: - nickname_changed = await _set_nickname(member, traits.get("username")) + nickname_changed = await _set_nickname(member, traits.get("username")) # type: ignore if not nickname_changed: logger.warning( @@ -246,13 +248,13 @@ async def process_account_identification( ) if traits.get("mp_user_id"): - htb_user_details = await get_user_details(traits.get("mp_user_id")) - await process_labs_identification(htb_user_details, member, bot) + htb_user_details = await get_user_details(traits.get("mp_user_id")) or {} # type: ignore + await process_labs_identification(htb_user_details, member, bot) # type: ignore if not nickname_changed: logger.debug( f"Falling back on HTB username to set nickname for {member.name} with ID {member.id}." - ) + ) await _set_nickname(member, htb_user_details["username"]) if traits.get("banned", False) == True: # noqa: E712 - explicit bool only, no truthiness @@ -264,7 +266,7 @@ async def process_labs_identification( htb_user_details: Dict[str, str], user: Optional[Member | User], bot: Bot ) -> Optional[List[Role]]: """Returns roles to assign if identification was successfully processed.""" - htb_uid = htb_user_details["user_id"] + htb_uid = int(htb_user_details["user_id"]) if isinstance(user, Member): member = user guild = member.guild @@ -278,10 +280,8 @@ async def process_labs_identification( raise GuildNotFound(f"Could not identify member {user} in guild.") to_remove = [] - for role in member.roles: - if role.id in settings.role_groups.get("ALL_RANKS") + settings.role_groups.get( - "ALL_POSITIONS" - ): + for role in member.roles: # type: ignore + if role.id in (settings.role_groups.get("ALL_RANKS") or []) + (settings.role_groups.get("ALL_POSITIONS") or []): to_remove.append(guild.get_role(role.id)) to_assign = [] @@ -293,12 +293,12 @@ async def process_labs_identification( "Staff", ]: to_assign.append( - guild.get_role(settings.get_post_or_rank(htb_user_details["rank"])) + guild.get_role(settings.get_post_or_rank(htb_user_details["rank"]) or -1) ) season_rank = await get_season_rank(htb_uid) - if season_rank: - to_assign.append(guild.get_role(settings.get_season(season_rank))) + if isinstance(season_rank, str): + to_assign.append(guild.get_role(settings.get_season(season_rank) or -1)) if htb_user_details["vip"]: to_assign.append(guild.get_role(settings.roles.VIP)) @@ -312,7 +312,7 @@ async def process_labs_identification( elif position <= 10: pos_top = "10" if pos_top: - to_assign.append(guild.get_role(settings.get_post_or_rank(pos_top))) + to_assign.append(guild.get_role(settings.get_post_or_rank(pos_top) or -1)) if htb_user_details["machines"]: to_assign.append(guild.get_role(settings.roles.BOX_CREATOR)) if htb_user_details["challenges"]: @@ -321,8 +321,8 @@ async def process_labs_identification( # We don't need to remove any roles that are going to be assigned again to_remove = list(set(to_remove) - set(to_assign)) if to_remove: - await member.remove_roles(*to_remove, atomic=True) + await member.remove_roles(*to_remove, atomic=True) # type: ignore if to_assign: - await member.add_roles(*to_assign, atomic=True) + await member.add_roles(*to_assign, atomic=True) # type: ignore return to_assign diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index d643248..10363a6 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -9,7 +9,7 @@ class AccountHandler(BaseHandler): - async def handle(self, body: WebhookBody, bot: Bot) -> dict: + async def handle(self, body: WebhookBody, bot: Bot) -> """ Handles incoming webhook events and performs actions accordingly. @@ -85,7 +85,7 @@ async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: # Use the generic ban helper to handle all the complex logic result = await handle_platform_ban_or_update( bot=bot, - guild=bot.guild, + guild=bot.guilds[0], member=member, expires_timestamp=expires_ts, reason=f"Platform Ban - {reason}", diff --git a/src/webhooks/types.py b/src/webhooks/types.py index 9812ccb..0a7387b 100644 --- a/src/webhooks/types.py +++ b/src/webhooks/types.py @@ -1,18 +1,21 @@ from enum import Enum -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Extra, Field class WebhookEvent(Enum): ACCOUNT_LINKED = "DiscordAccountLinked" ACCOUNT_UNLINKED = "DiscordAccountUnlinked" ACCOUNT_DELETED = "UserAccountDeleted" + ACCOUNT_BANNED = "UserAccountBanned" CERTIFICATE_AWARDED = "CertificateAwarded" RANK_UP = "RankUp" HOF_CHANGE = "HofChange" SUBSCRIPTION_CHANGE = "SubscriptionChange" CONTENT_RELEASED = "ContentReleased" NAME_CHANGE = "NameChange" + SEASON_RANK_CHANGE = "SeasonRankChange" + PROLAB_COMPLETED = "ProlabCompleted" class Platform(Enum): @@ -24,9 +27,9 @@ class Platform(Enum): class WebhookBody(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra=Extra.allow) platform: Platform event: WebhookEvent - properties: dict | None - traits: dict | None + properties: dict = Field(default_factory=dict) + traits: dict = Field(default_factory=dict) From bf5b82757f6ca4eeabf7420e90246311316cc272 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 30 Jun 2025 20:48:10 -0400 Subject: [PATCH 20/29] =?UTF-8?q?=E2=9C=A8=20Additional=20events,=20bux=20?= =?UTF-8?q?fix,=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/account.py | 27 +++-- src/webhooks/handlers/base.py | 17 ++- src/webhooks/handlers/mp.py | 197 +++++++++++++++++++++++++++++++ src/webhooks/server.py | 6 +- src/webhooks/types.py | 2 - 5 files changed, 231 insertions(+), 18 deletions(-) create mode 100644 src/webhooks/handlers/mp.py diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 10363a6..1cd76b3 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -9,7 +9,7 @@ class AccountHandler(BaseHandler): - async def handle(self, body: WebhookBody, bot: Bot) -> + async def handle(self, body: WebhookBody, bot: Bot): """ Handles incoming webhook events and performs actions accordingly. @@ -24,6 +24,8 @@ async def handle(self, body: WebhookBody, bot: Bot) -> await self.handle_account_deleted(body, bot) elif body.event == WebhookEvent.ACCOUNT_BANNED: await self.handle_account_banned(body, bot) + else: + raise ValueError(f"Invalid event: {body.event}") async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: """ @@ -35,11 +37,10 @@ async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: member = await self.get_guild_member(discord_id, bot) await process_account_identification( member, - bot, + bot, # type: ignore traits=self.merge_properties_and_traits(body.properties, body.traits), ) - await bot.send_message( - settings.channels.VERIFY_LOGS, + await bot.guilds[0].get_channel(settings.channels.VERIFY_LOGS).send( # type: ignore f"Account linked: {account_id} -> ({member.mention} ({member.id})", ) @@ -48,6 +49,8 @@ async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: extra={"account_id": account_id, "discord_id": discord_id}, ) + return self.success() + async def handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account unlinked event. @@ -57,7 +60,9 @@ async def handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: member = await self.get_guild_member(discord_id, bot) - await member.remove_roles(settings.roles.VERIFIED, atomic=True) + await member.remove_roles(bot.guilds[0].get_role(settings.roles.VERIFIED), atomic=True) # type: ignore + + return self.success() async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: """ @@ -80,11 +85,11 @@ async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: self.logger.warning( f"Cannot ban user {discord_id}- not found in guild", extra=extra ) - return + return self.fail() # Use the generic ban helper to handle all the complex logic result = await handle_platform_ban_or_update( - bot=bot, + bot=bot, # type: ignore guild=bot.guilds[0], member=member, expires_timestamp=expires_ts, @@ -101,6 +106,8 @@ async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: f"Platform ban handling result: {result['action']}", extra=extra ) + return self.success() + async def handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account deleted event. @@ -114,6 +121,8 @@ async def handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: f"Cannot delete account {account_id}- not found in guild", extra={"account_id": account_id, "discord_id": discord_id}, ) - return + return self.fail() + + await member.remove_roles(bot.guilds[0].get_role(settings.roles.VERIFIED), atomic=True) # type: ignore - await member.remove_roles(settings.roles.VERIFIED, atomic=True) + return self.success() diff --git a/src/webhooks/handlers/base.py b/src/webhooks/handlers/base.py index 9a5be09..b604f01 100644 --- a/src/webhooks/handlers/base.py +++ b/src/webhooks/handlers/base.py @@ -27,7 +27,7 @@ def __init__(self): async def handle(self, body: WebhookBody, bot: Bot) -> dict: pass - async def get_guild_member(self, discord_id: int, bot: Bot) -> Member: + async def get_guild_member(self, discord_id: int | str, bot: Bot) -> Member: """ Fetches a guild member from the Discord server. @@ -41,9 +41,10 @@ async def get_guild_member(self, discord_id: int, 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(discord_id) + member = await guild.fetch_member(int(discord_id)) return member except NotFound as exc: @@ -73,13 +74,13 @@ def validate_property(self, property: T | None, name: str) -> T: return property - def validate_discord_id(self, discord_id: str | int) -> int: + def validate_discord_id(self, discord_id: str | int | None) -> int | str: """ Validates the Discord ID. See validate_property function. """ return self.validate_property(discord_id, "Discord ID") - def validate_account_id(self, account_id: str | int) -> int: + def validate_account_id(self, account_id: str | int | None) -> int | str: """ Validates the Account ID. See validate_property function. """ @@ -117,3 +118,11 @@ def get_platform_properties(self, body: WebhookBody) -> dict[str, int | None]: ), } return properties + + @staticmethod + def success(): + return {"success": True} + + @staticmethod + def fail(): + return {"success": False} \ No newline at end of file diff --git a/src/webhooks/handlers/mp.py b/src/webhooks/handlers/mp.py new file mode 100644 index 0000000..9b884c8 --- /dev/null +++ b/src/webhooks/handlers/mp.py @@ -0,0 +1,197 @@ +import discord + +from datetime import datetime +from discord import Bot, Member, Role + +from typing import Literal +from sqlalchemy import select + +from src.core import settings +from src.webhooks.handlers.base import BaseHandler +from src.webhooks.types import WebhookBody, WebhookEvent + + +class AccountHandler(BaseHandler): + async def handle(self, body: WebhookBody, bot: Bot): + """ + Handles incoming webhook events and performs actions accordingly. + + This function processes different webhook events originating from the + HTB Account. + """ + if body.event == WebhookEvent.NAME_CHANGE: + return await self.name_change(body, bot) + elif body.event == WebhookEvent.HOF_CHANGE: + return await self.handle_hof_change(body, bot) + elif body.event == WebhookEvent.RANK_UP: + return await self.handle_rank_up(body, bot) + elif body.event == WebhookEvent.SUBSCRIPTION_CHANGE: + return await self.handle_subscription_change(body, bot) + elif body.event == WebhookEvent.CERTIFICATE_AWARDED: + return await self.handle_certificate_awarded(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(body.properties.get("discord_id")) + _ = self.validate_account_id(body.properties.get("account_id")) + certificate_id = self.validate_property(body.properties.get("certificate_id"), "certificate_id") + + member = await self.get_guild_member(discord_id, bot) + certificate_role_id = settings.get_academy_cert_role(int(certificate_id)) + + if certificate_role_id: + await member.add_roles(bot.guilds[0].get_role(certificate_role_id), atomic=True) # type: ignore + + return self.success() + + async def handle_subscription_change(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the subscription change event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + _ = self.validate_account_id(body.properties.get("account_id")) + subscription_name = self.validate_property( + body.properties.get("subscription_name"), "subscription_name" + ) + + member = await self.get_guild_member(discord_id, bot) + + role = settings.get_post_or_rank(subscription_name) + if not role: + raise ValueError(f"Invalid subscription name: {subscription_name}") + + await member.add_roles(bot.guilds[0].get_role(role), atomic=True) # type: ignore + return self.success() + + async def name_change(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the name change event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + _ = self.validate_account_id(body.properties.get("account_id")) + name = self.validate_property(body.properties.get("name"), "name") + + member = await self.get_guild_member(discord_id, bot) + await member.edit(nick=name) + return self.success() + + async def handle_hof_change(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the HOF change event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + account_id = self.validate_account_id(body.properties.get("account_id")) + hof_tier: Literal["1", "10"] = self.validate_property( + body.properties.get("hof_tier"), "hof_tier" + ) + hof_roles = { + "1": bot.guilds[0].get_role(settings.roles.RANK_ONE), + "10": bot.guilds[0].get_role(settings.roles.RANK_TEN), + } + + member = await self.get_guild_member(discord_id, bot) + member_roles = member.roles + + if not member: + msg = f"Cannot find member {discord_id}" + self.logger.warning( + msg, extra={"account_id": account_id, "discord_id": discord_id} + ) + raise ValueError(msg) + + async def _swap_hof_roles(member: Member, role_to_grant: Role | None): + """Grants a HOF role to a member and removes the other HOF role""" + if not role_to_grant: + return + + member_hof_role = next( + (r for r in member_roles if r in hof_roles.values()), None + ) + if member_hof_role: + await member.remove_roles(member_hof_role, atomic=True) + await member.add_roles(role_to_grant, atomic=True) + + if hof_tier == "1": + # Find existing top 1 user and make them a top 10 + if existing_top_one_user := await self._find_user_with_role( + bot, hof_roles["1"] + ): + if existing_top_one_user.id != member.id: + await _swap_hof_roles(existing_top_one_user, hof_roles["10"]) + else: + return self.success() + + # Grant top 1 role to member + await _swap_hof_roles(member, hof_roles["1"]) + return self.success() + + # Just grant top 10 role to member + elif hof_tier == "10": + await _swap_hof_roles(member, hof_roles["10"]) + return self.success() + + else: + err = ValueError(f"Invalid HOF tier: {hof_tier}") + self.logger.error( + err, + extra={ + "account_id": account_id, + "discord_id": discord_id, + "hof_tier": hof_tier, + }, + ) + raise err + + async def handle_rank_up(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the rank up event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + account_id = self.validate_account_id(body.properties.get("account_id")) + rank = self.validate_property(body.properties.get("rank"), "rank") + + member = await self.get_guild_member(discord_id, bot) + + rank_roles = [ + bot.guilds[0].get_role(int(r)) for r in settings.role_groups["ALL_RANKS"] + ] # All rank roles + new_role = next( + (r for r in rank_roles if r and r.name == rank), None + ) # Get passed rank as role from rank roles + old_role = next( + (r for r in member.roles if r in rank_roles), None + ) # Find existing rank role on user + + if old_role: + await member.remove_roles(old_role, atomic=True) # Yeet the old role + + if new_role: + await member.add_roles(new_role, atomic=True) # Add the new role + + if not new_role: + # Why are you passing me BS roles? + err = ValueError(f"Cannot find role for '{rank}'") + self.logger.error( + err, + extra={ + "account_id": account_id, + "discord_id": discord_id, + "rank": rank, + }, + ) + raise err + + return self.success() + + 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) diff --git a/src/webhooks/server.py b/src/webhooks/server.py index 9ae23c1..a367118 100644 --- a/src/webhooks/server.py +++ b/src/webhooks/server.py @@ -7,7 +7,7 @@ from fastapi import FastAPI, HTTPException, Request from hypercorn.asyncio import serve as hypercorn_serve from hypercorn.config import Config as HypercornConfig -from pydantic import ValidationError, +from pydantic import ValidationError from src.bot import bot from src.core import settings @@ -35,7 +35,7 @@ def verify_signature(body: dict, signature: str, secret: str) -> bool: if not signature: return False - digest = hmac.new(secret.encode(), body, hashlib.sha1).hexdigest() + digest = hmac.new(secret.encode(), body, hashlib.sha1).hexdigest() # type: ignore return hmac.compare_digest(signature, digest) @@ -61,7 +61,7 @@ async def webhook_handler(request: Request) -> Dict[str, Any]: body = await request.body() signature = request.headers.get("X-Signature") - if not verify_signature(body, signature, settings.WEBHOOK_TOKEN): + if not verify_signature(body, signature, settings.WEBHOOK_TOKEN): # type: ignore logger.warning("Unauthorized webhook request") raise HTTPException(status_code=401, detail="Unauthorized") diff --git a/src/webhooks/types.py b/src/webhooks/types.py index 0a7387b..2fabb3b 100644 --- a/src/webhooks/types.py +++ b/src/webhooks/types.py @@ -12,10 +12,8 @@ class WebhookEvent(Enum): RANK_UP = "RankUp" HOF_CHANGE = "HofChange" SUBSCRIPTION_CHANGE = "SubscriptionChange" - CONTENT_RELEASED = "ContentReleased" NAME_CHANGE = "NameChange" SEASON_RANK_CHANGE = "SeasonRankChange" - PROLAB_COMPLETED = "ProlabCompleted" class Platform(Enum): From 86a5be444050df36a6c1a9f9e9a788d38ea126bf Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 30 Jun 2025 20:51:27 -0400 Subject: [PATCH 21/29] =?UTF-8?q?=F0=9F=90=9B=20Fix=20MP=20handler,=20add?= =?UTF-8?q?=20to=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/__init__.py | 6 ++++-- src/webhooks/handlers/mp.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/webhooks/handlers/__init__.py b/src/webhooks/handlers/__init__.py index 601943c..4ea73f1 100644 --- a/src/webhooks/handlers/__init__.py +++ b/src/webhooks/handlers/__init__.py @@ -1,16 +1,18 @@ from discord import Bot +from typing import Any from src.webhooks.handlers.account import AccountHandler +from src.webhooks.handlers.mp import MPHandler from src.webhooks.types import Platform, WebhookBody -handlers = {Platform.ACCOUNT: AccountHandler().handle} +handlers = {Platform.ACCOUNT: AccountHandler().handle, Platform.MAIN: MPHandler().handle} def can_handle(platform: Platform) -> bool: return platform in handlers.keys() -def handle(body: WebhookBody, bot: Bot) -> any: +def handle(body: WebhookBody, bot: Bot) -> Any: platform = body.platform if not can_handle(platform): diff --git a/src/webhooks/handlers/mp.py b/src/webhooks/handlers/mp.py index 9b884c8..8958554 100644 --- a/src/webhooks/handlers/mp.py +++ b/src/webhooks/handlers/mp.py @@ -11,7 +11,7 @@ from src.webhooks.types import WebhookBody, WebhookEvent -class AccountHandler(BaseHandler): +class MPHandler(BaseHandler): async def handle(self, body: WebhookBody, bot: Bot): """ Handles incoming webhook events and performs actions accordingly. From aa24de1f65eec20017011315bc749af51c38a098 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Mon, 30 Jun 2025 21:06:04 -0400 Subject: [PATCH 22/29] =?UTF-8?q?=E2=9C=85=20Tests=20&=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/helpers/ban.py | 10 ++- src/webhooks/handlers/account.py | 8 +-- tests/src/helpers/test_ban.py | 30 +++++++-- tests/src/webhooks/handlers/test_account.py | 69 +++++++++++++++------ 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/src/helpers/ban.py b/src/helpers/ban.py index f414b21..19e8bee 100644 --- a/src/helpers/ban.py +++ b/src/helpers/ban.py @@ -283,7 +283,10 @@ async def ban_member_with_epoch( if author is None: author = bot.user - assert isinstance(author, Member) # For linting + + # Author should never be None at this point + if author is None: + raise ValueError("Author cannot be None") ban = Ban( user_id=member.id, @@ -508,7 +511,10 @@ async def mute_member( if author is None: author = bot.user - assert isinstance(author, Member) # For linting + + # Author should never be None at this point + if author is None: + raise ValueError("Author cannot be None") role = guild.get_role(settings.roles.MUTED) diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 1cd76b3..b69d6a7 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -17,13 +17,13 @@ async def handle(self, body: WebhookBody, bot: Bot): HTB Account. """ if body.event == WebhookEvent.ACCOUNT_LINKED: - await self.handle_account_linked(body, bot) + return await self.handle_account_linked(body, bot) elif body.event == WebhookEvent.ACCOUNT_UNLINKED: - await self.handle_account_unlinked(body, bot) + return await self.handle_account_unlinked(body, bot) elif body.event == WebhookEvent.ACCOUNT_DELETED: - await self.handle_account_deleted(body, bot) + return await self.handle_account_deleted(body, bot) elif body.event == WebhookEvent.ACCOUNT_BANNED: - await self.handle_account_banned(body, bot) + return await self.handle_account_banned(body, bot) else: raise ValueError(f"Invalid event: {body.event}") diff --git a/tests/src/helpers/test_ban.py b/tests/src/helpers/test_ban.py index 99d3bdc..1771928 100644 --- a/tests/src/helpers/test_ban.py +++ b/tests/src/helpers/test_ban.py @@ -1,8 +1,10 @@ +from datetime import datetime, timezone from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch import pytest from discord import Forbidden, HTTPException +from datetime import datetime, timezone from src.helpers.ban import _check_member, _dm_banned_member, ban_member from src.helpers.responses import SimpleResponse @@ -116,11 +118,15 @@ async def test_ban_member_valid_duration(self, bot, guild, member, author): evidence = "Some evidence" member.display_name = "Banned Member" + # Use a future timestamp instead of a past one + future_timestamp = int((datetime.now(tz=timezone.utc).timestamp() + 86400)) # 1 day from now + expected_date = datetime.fromtimestamp(future_timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + with ( mock.patch("src.helpers.ban._check_member", return_value=None), mock.patch("src.helpers.ban._dm_banned_member", return_value=True), mock.patch("src.helpers.ban._get_ban_or_create", return_value=(1, False)), - mock.patch("src.helpers.ban.validate_duration", return_value=(1684276900, "")), + mock.patch("src.helpers.ban.validate_duration", return_value=(future_timestamp, "")), ): mock_channel = helpers.MockTextChannel() mock_channel.send.return_value = MagicMock() @@ -128,7 +134,7 @@ async def test_ban_member_valid_duration(self, bot, guild, member, author): result = await ban_member(bot, guild, member, duration, reason, evidence) assert isinstance(result, SimpleResponse) - assert result.message == f"{member.display_name} ({member.id}) has been banned until 2023-05-16 22:41:40 " \ + assert result.message == f"{member.display_name} ({member.id}) has been banned until {expected_date} " \ f"(UTC)." @pytest.mark.asyncio @@ -153,12 +159,15 @@ async def test_ban_member_permanently_success(self, bot, guild, member, author): evidence = "Some evidence" member.display_name = "Banned Member" + # Use a future timestamp instead of a past one + future_timestamp = int((datetime.now(tz=timezone.utc).timestamp() + 86400)) # 1 day from now + # Patching the necessary classes and functions with ( mock.patch("src.helpers.ban._check_member", return_value=None), mock.patch("src.helpers.ban._dm_banned_member", return_value=True), mock.patch("src.helpers.ban._get_ban_or_create", return_value=(1, False)), - mock.patch("src.helpers.ban.validate_duration", return_value=(1684276900, "")), + mock.patch("src.helpers.ban.validate_duration", return_value=(future_timestamp, "")), ): response = await ban_member(bot, guild, member, duration, reason, evidence, author, False) assert isinstance(response, SimpleResponse) @@ -171,12 +180,15 @@ async def test_ban_member_no_reason_success(self, bot, guild, member, author): evidence = "Some evidence" member.display_name = "Banned Member" + # Use a future timestamp instead of a past one + future_timestamp = int((datetime.now(tz=timezone.utc).timestamp() + 86400)) # 1 day from now + # Patching the necessary classes and functions with ( mock.patch("src.helpers.ban._check_member", return_value=None), mock.patch("src.helpers.ban._dm_banned_member", return_value=True), mock.patch("src.helpers.ban._get_ban_or_create", return_value=(1, False)), - mock.patch("src.helpers.ban.validate_duration", return_value=(1684276900, "")), + mock.patch("src.helpers.ban.validate_duration", return_value=(future_timestamp, "")), ): response = await ban_member(bot, guild, member, duration, reason, evidence, author, False) assert isinstance(response, SimpleResponse) @@ -189,11 +201,14 @@ async def test_ban_member_no_author_success(self, bot, guild, member): evidence = "Some evidence" member.display_name = "Banned Member" + # Use a future timestamp instead of a past one + future_timestamp = int((datetime.now(tz=timezone.utc).timestamp() + 86400)) # 1 day from now + with ( mock.patch("src.helpers.ban._check_member", return_value=None), mock.patch("src.helpers.ban._dm_banned_member", return_value=True), mock.patch("src.helpers.ban._get_ban_or_create", return_value=(1, False)), - mock.patch("src.helpers.ban.validate_duration", return_value=(1684276900, "")), + mock.patch("src.helpers.ban.validate_duration", return_value=(future_timestamp, "")), ): response = await ban_member(bot, guild, member, duration, reason, evidence, None, False) assert isinstance(response, SimpleResponse) @@ -206,11 +221,14 @@ async def test_ban_already_exists(self, bot, guild, member, author): evidence = "Some evidence" member.display_name = "Banned Member" + # Use a future timestamp instead of a past one + future_timestamp = int((datetime.now(tz=timezone.utc).timestamp() + 86400)) # 1 day from now + with ( mock.patch("src.helpers.ban._check_member", return_value=None), mock.patch("src.helpers.ban._dm_banned_member", return_value=True), mock.patch("src.helpers.ban._get_ban_or_create", return_value=(1, True)), - mock.patch("src.helpers.ban.validate_duration", return_value=(1684276900, "")), + mock.patch("src.helpers.ban.validate_duration", return_value=(future_timestamp, "")), ): response = await ban_member(bot, guild, member, duration, reason, evidence, author) assert isinstance(response, SimpleResponse) diff --git a/tests/src/webhooks/handlers/test_account.py b/tests/src/webhooks/handlers/test_account.py index 91cf195..a103b31 100644 --- a/tests/src/webhooks/handlers/test_account.py +++ b/tests/src/webhooks/handlers/test_account.py @@ -57,22 +57,36 @@ async def test_handle_account_unlinked_event(self, bot): @pytest.mark.asyncio async def test_handle_account_deleted_event(self, bot): - """Test handle method with ACCOUNT_DELETED event (method not implemented).""" + """Test handle method with ACCOUNT_DELETED event.""" handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + mock_member = helpers.MockMember(id=discord_id) + body = WebhookBody( platform=Platform.ACCOUNT, event=WebhookEvent.ACCOUNT_DELETED, - properties={"discord_id": 123456789, "account_id": 987654321}, + properties={"discord_id": discord_id, "account_id": account_id}, traits={}, ) - # The handle_account_deleted method is not implemented, so this should raise AttributeError - with pytest.raises(AttributeError): - await handler.handle(body, bot) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.account.settings") as mock_settings, + ): + mock_settings.roles.VERIFIED = helpers.MockRole(id=99999, name="Verified") + mock_member.remove_roles = AsyncMock() + + result = await handler.handle(body, bot) + + # Should succeed and return success + assert result == handler.success() @pytest.mark.asyncio async def test_handle_unknown_event(self, bot): - """Test handle method with unknown event does nothing.""" + """Test handle method with unknown event raises ValueError.""" handler = AccountHandler() body = WebhookBody( platform=Platform.ACCOUNT, @@ -81,8 +95,9 @@ async def test_handle_unknown_event(self, bot): traits={}, ) - # Should not raise any exceptions, just do nothing - await handler.handle(body, bot) + # Should raise ValueError for unknown event + with pytest.raises(ValueError, match="Invalid event"): + await handler.handle(body, bot) @pytest.mark.asyncio async def test_handle_account_linked_success(self, bot): @@ -99,10 +114,6 @@ async def test_handle_account_linked_success(self, bot): traits={"htb_user_id": 555}, ) - # Create a custom bot mock without spec_set restrictions for this test - custom_bot = MagicMock() - custom_bot.send_message = AsyncMock() - with ( patch.object( handler, "validate_discord_id", return_value=discord_id @@ -134,29 +145,39 @@ async def test_handle_account_linked_success(self, bot): ): mock_settings.channels.VERIFY_LOGS = 12345 - await handler.handle_account_linked(body, custom_bot) + # Mock the bot's guild structure and channel + mock_channel = MagicMock() + mock_channel.send = AsyncMock() + mock_guild = MagicMock() + mock_guild.get_channel.return_value = mock_channel + bot.guilds = [mock_guild] + + result = await handler.handle_account_linked(body, bot) # Verify all method calls mock_validate_discord.assert_called_once_with(discord_id) mock_validate_account.assert_called_once_with(account_id) - mock_get_member.assert_called_once_with(discord_id, custom_bot) + mock_get_member.assert_called_once_with(discord_id, bot) mock_merge.assert_called_once_with(body.properties, body.traits) mock_process.assert_called_once_with( mock_member, - custom_bot, + bot, traits={ "discord_id": discord_id, "account_id": account_id, "htb_user_id": 555, }, ) - custom_bot.send_message.assert_called_once_with( - 12345, f"Account linked: {account_id} -> (@testuser ({discord_id})" + mock_channel.send.assert_called_once_with( + f"Account linked: {account_id} -> (@testuser ({discord_id})" ) mock_log.assert_called_once_with( f"Account {account_id} linked to {discord_id}", extra={"account_id": account_id, "discord_id": discord_id}, ) + + # Should return success + assert result == handler.success() @pytest.mark.asyncio async def test_handle_account_linked_invalid_discord_id(self, bot): @@ -269,18 +290,26 @@ async def test_handle_account_unlinked_success(self, bot): ) as mock_get_member, patch("src.webhooks.handlers.account.settings") as mock_settings, ): - mock_settings.roles.VERIFIED = helpers.MockRole(id=99999, name="Verified") + # Mock the bot's guild structure and role + mock_role = helpers.MockRole(id=99999, name="Verified") + mock_guild = MagicMock() + mock_guild.get_role.return_value = mock_role + bot.guilds = [mock_guild] + mock_settings.roles.VERIFIED = 99999 mock_member.remove_roles = AsyncMock() - await handler.handle_account_unlinked(body, bot) + result = await handler.handle_account_unlinked(body, bot) # Verify all method calls mock_validate_discord.assert_called_once_with(discord_id) mock_validate_account.assert_called_once_with(account_id) mock_get_member.assert_called_once_with(discord_id, bot) mock_member.remove_roles.assert_called_once_with( - mock_settings.roles.VERIFIED, atomic=True + mock_role, atomic=True ) + + # Should return success + assert result == handler.success() @pytest.mark.asyncio async def test_handle_account_unlinked_invalid_discord_id(self, bot): From 86f0a38f46198967f49fd5b8785f789569ad4d6b Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Wed, 16 Jul 2025 23:38:12 -0400 Subject: [PATCH 23/29] =?UTF-8?q?=F0=9F=90=9B=20Ensure=20ban=20continuity?= =?UTF-8?q?=20when=20DB=20is=20out=20of=20sync=20with=20discord=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/helpers/ban.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/helpers/ban.py b/src/helpers/ban.py index 19e8bee..92fe22e 100644 --- a/src/helpers/ban.py +++ b/src/helpers/ban.py @@ -302,8 +302,13 @@ async def ban_member_with_epoch( moderator_id=author.id, date=datetime.now().date(), ) + ban_id, is_existing = await _get_ban_or_create(member, ban, infraction) if is_existing: + try: + await guild.ban(member, reason=reason, delete_message_seconds=0) + except NotFound: + pass return SimpleResponse( message=f"A ban with id: {ban_id} already exists for member {member}", delete_after=None, From 720f3f923297b91435be1fb0db5fb9b0fd28d74f Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Wed, 16 Jul 2025 23:39:25 -0400 Subject: [PATCH 24/29] =?UTF-8?q?=E2=9C=A8=20Rework=20`get=5Fuser=5Fdetail?= =?UTF-8?q?s`=20to=20use=20v4=20API.=20Major=20refactor=20of=20labs=20iden?= =?UTF-8?q?tification=20code.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/helpers/verification.py | 317 +++++++++++++++++++++++++++--------- 1 file changed, 240 insertions(+), 77 deletions(-) diff --git a/src/helpers/verification.py b/src/helpers/verification.py index 7b94b6b..7e8358d 100644 --- a/src/helpers/verification.py +++ b/src/helpers/verification.py @@ -1,4 +1,5 @@ import logging +import traceback from typing import Dict, List, Optional, Any, TypeVar import aiohttp @@ -10,6 +11,7 @@ Member, Role, User, + Guild, ) from discord.ext.commands import GuildNotFound, MemberNotFound @@ -89,35 +91,51 @@ async def send_verification_instructions( return await ctx.respond("Please check your DM for instructions.", ephemeral=True) -async def get_user_details(account_identifier: str) -> Optional[Dict]: + +def get_labs_session() -> aiohttp.ClientSession: + """Get a session for the HTB Labs API.""" + return aiohttp.ClientSession(headers={"Authorization": f"Bearer {settings.HTB_API_KEY}"}) + + +async def get_user_details(labs_id: int | str) -> dict: """Get user details from HTB.""" - acc_id_url = f"{settings.API_URL}/discord/identifier/{account_identifier}?secret={settings.HTB_API_SECRET}" - async with aiohttp.ClientSession() as session: - async with session.get(acc_id_url) as r: + if not labs_id: + return {} + + user_profile_api_url = f"{settings.API_V4_URL}/user/profile/basic/{labs_id}" + user_content_api_url = f"{settings.API_V4_URL}/user/profile/content/{labs_id}" + + async with get_labs_session() as session: + async with session.get(user_profile_api_url) as r: if r.status == 200: - response = await r.json() - elif r.status == 404: - logger.debug( - "Account identifier has been regenerated since last identification. Cannot re-verify." + profile_response = await r.json() + else: + logger.error( + f"Non-OK HTTP status code returned from user details lookup: {r.status}." ) - response = None + profile_response = {} + + async with session.get(user_content_api_url) as r: + if r.status == 200: + content_response = await r.json() else: logger.error( - f"Non-OK HTTP status code returned from identifier lookup: {r.status}." + f"Non-OK HTTP status code returned from user content lookup: {r.status}." ) - response = None + content_response = {} - return response + profile = profile_response.get("profile", {}) + profile["content"] = content_response.get("profile", {}).get("content", {}) + return profile async def get_season_rank(htb_uid: int) -> str | None: """Get season rank from HTB.""" - headers = {"Authorization": f"Bearer {settings.HTB_API_KEY}"} season_api_url = f"{settings.API_V4_URL}/season/end/{settings.SEASON_ID}/{htb_uid}" - async with aiohttp.ClientSession() as session: - async with session.get(season_api_url, headers=headers) as r: + async with get_labs_session() as session: + async with session.get(season_api_url) as r: if r.status == 200: response = await r.json() elif r.status == 404: @@ -233,7 +251,11 @@ async def process_account_identification( bot (Bot): The bot instance. traits (dict[str, str] | None): Optional user traits to process. """ - await member.add_roles(member.guild.get_role(settings.roles.VERIFIED), atomic=True) # type: ignore + try: + await member.add_roles(member.guild.get_role(settings.roles.VERIFIED), atomic=True) # type: ignore + except Exception as e: + logger.error(f"Failed to add VERIFIED role to user {member.id}: {e}") + # Don't raise - continue with other operations nickname_changed = False @@ -248,81 +270,222 @@ async def process_account_identification( ) if traits.get("mp_user_id"): - htb_user_details = await get_user_details(traits.get("mp_user_id")) or {} # type: ignore - await process_labs_identification(htb_user_details, member, bot) # type: ignore - - if not nickname_changed: - logger.debug( - f"Falling back on HTB username to set nickname for {member.name} with ID {member.id}." - ) - await _set_nickname(member, htb_user_details["username"]) + try: + logger.debug(f"MP user ID: {traits.get('mp_user_id', None)}") + htb_user_details = await get_user_details(traits.get("mp_user_id", None)) + if htb_user_details: + await process_labs_identification(htb_user_details, member, bot) # type: ignore + + if not nickname_changed and htb_user_details.get("username"): + logger.debug( + f"Falling back on HTB username to set nickname for {member.name} with ID {member.id}." + ) + await _set_nickname(member, htb_user_details["username"]) + except Exception as e: + logger.error(f"Failed to process labs identification for user {member.id}: {e}") + # Don't raise - this is not critical if traits.get("banned", False) == True: # noqa: E712 - explicit bool only, no truthiness - await _handle_banned_user(member, bot) - return + try: + logger.debug(f"Handling banned user {member.id}") + await _handle_banned_user(member, bot) + return + except Exception as e: + logger.error(f"Failed to handle banned user {member.id}: {e}") + logger.exception(traceback.format_exc()) + # Don't raise - continue processing async def process_labs_identification( - htb_user_details: Dict[str, str], user: Optional[Member | User], bot: Bot + htb_user_details: dict, user: Optional[Member | User], bot: Bot ) -> Optional[List[Role]]: """Returns roles to assign if identification was successfully processed.""" - htb_uid = int(htb_user_details["user_id"]) + + # Resolve member and guild + member, guild = await _resolve_member_and_guild(user, bot) + + # Get roles to remove and assign + to_remove = _get_roles_to_remove(member, guild) + to_assign = await _process_role_assignments(htb_user_details, guild) + + # Remove roles that will be reassigned + to_remove = list(set(to_remove) - set(to_assign)) + + # Apply role changes + await _apply_role_changes(member, to_remove, to_assign) + + return to_assign + + +async def _resolve_member_and_guild( + user: Optional[Member | User], bot: Bot +) -> tuple[Member, Guild]: + """Resolve member and guild from user object.""" if isinstance(user, Member): - member = user - guild = member.guild - # This will only work if the user and the bot share only one guild. - elif isinstance(user, User) and len(user.mutual_guilds) == 1: + return user, user.guild + + if isinstance(user, User) and len(user.mutual_guilds) == 1: guild = user.mutual_guilds[0] member = await bot.get_member_or_user(guild, user.id) if not member: raise MemberNotFound(str(user.id)) - else: - raise GuildNotFound(f"Could not identify member {user} in guild.") + return member, guild # type: ignore + + raise GuildNotFound(f"Could not identify member {user} in guild.") - to_remove = [] - for role in member.roles: # type: ignore - if role.id in (settings.role_groups.get("ALL_RANKS") or []) + (settings.role_groups.get("ALL_POSITIONS") or []): - to_remove.append(guild.get_role(role.id)) +def _get_roles_to_remove(member: Member, guild: Guild) -> list[Role]: + """Get existing roles that should be removed.""" + to_remove = [] + try: + all_ranks = settings.role_groups.get("ALL_RANKS", []) + all_positions = settings.role_groups.get("ALL_POSITIONS", []) + removable_role_ids = all_ranks + all_positions + + for role in member.roles: + if role.id in removable_role_ids: + guild_role = guild.get_role(role.id) + if guild_role: + to_remove.append(guild_role) + except Exception as e: + logger.error(f"Error processing existing roles for user {member.id}: {e}") + return to_remove + + +async def _process_role_assignments( + htb_user_details: dict, guild: Guild +) -> list[Role]: + """Process role assignments based on HTB user details.""" to_assign = [] - if htb_user_details["rank"] not in [ - "Deleted", - "Moderator", - "Ambassador", - "Admin", - "Staff", - ]: - to_assign.append( - guild.get_role(settings.get_post_or_rank(htb_user_details["rank"]) or -1) - ) + + # Process rank roles + to_assign.extend(_process_rank_roles(htb_user_details.get("rank", ""), guild)) + + # Process season rank roles + to_assign.extend(await _process_season_rank_roles(htb_user_details.get("id", ""), guild)) + + # Process VIP roles + to_assign.extend(_process_vip_roles(htb_user_details, guild)) + + # Process HOF position roles + to_assign.extend(_process_hof_position_roles(htb_user_details.get("ranking", "unranked"), guild)) + + # Process creator roles + to_assign.extend(_process_creator_roles(htb_user_details.get("content", {}), guild)) + + return to_assign - season_rank = await get_season_rank(htb_uid) - if isinstance(season_rank, str): - to_assign.append(guild.get_role(settings.get_season(season_rank) or -1)) - - if htb_user_details["vip"]: - to_assign.append(guild.get_role(settings.roles.VIP)) - if htb_user_details["dedivip"]: - to_assign.append(guild.get_role(settings.roles.VIP_PLUS)) - if htb_user_details["hof_position"] != "unranked": - position = int(htb_user_details["hof_position"]) - pos_top = None - if position == 1: - pos_top = "1" - elif position <= 10: - pos_top = "10" - if pos_top: - to_assign.append(guild.get_role(settings.get_post_or_rank(pos_top) or -1)) - if htb_user_details["machines"]: - to_assign.append(guild.get_role(settings.roles.BOX_CREATOR)) - if htb_user_details["challenges"]: - to_assign.append(guild.get_role(settings.roles.CHALLENGE_CREATOR)) - - # We don't need to remove any roles that are going to be assigned again - to_remove = list(set(to_remove) - set(to_assign)) - if to_remove: - await member.remove_roles(*to_remove, atomic=True) # type: ignore - if to_assign: - await member.add_roles(*to_assign, atomic=True) # type: ignore - return to_assign +def _process_rank_roles(rank: str, guild: Guild) -> list[Role]: + """Process rank-based role assignments.""" + roles = [] + + if rank and rank not in ["Deleted", "Moderator", "Ambassador", "Admin", "Staff"]: + role_id = settings.get_post_or_rank(rank) + if role_id: + role = guild.get_role(role_id) + if role: + roles.append(role) + + return roles + + +async def _process_season_rank_roles(mp_user_id: int, guild: Guild) -> list[Role]: + """Process season rank role assignments.""" + roles = [] + try: + season_rank = await get_season_rank(mp_user_id) + if isinstance(season_rank, str): + season_role_id = settings.get_season(season_rank) + if season_role_id: + season_role = guild.get_role(season_role_id) + if season_role: + roles.append(season_role) + except Exception as e: + logger.error(f"Error getting season rank for user {mp_user_id}: {e}") + return roles + + +def _process_vip_roles(htb_user_details: dict, guild: Guild) -> list[Role]: + """Process VIP role assignments.""" + roles = [] + try: + if htb_user_details.get("isVip", False): + vip_role = guild.get_role(settings.roles.VIP) + if vip_role: + roles.append(vip_role) + + if htb_user_details.get("isDedicatedVip", False): + vip_plus_role = guild.get_role(settings.roles.VIP_PLUS) + if vip_plus_role: + roles.append(vip_plus_role) + except Exception as e: + logger.error(f"Error processing VIP roles: {e}") + return roles + + +def _process_hof_position_roles(htb_user_ranking: str | int, guild: Guild) -> list[Role]: + """Process Hall of Fame position role assignments.""" + roles = [] + try: + hof_position = htb_user_ranking or "unranked" + logger.debug(f"HTB user ranking: {hof_position}") + if hof_position != "unranked": + position = int(hof_position) + pos_top = _get_position_tier(position) + + if pos_top: + pos_role_id = settings.get_post_or_rank(pos_top) + if pos_role_id: + pos_role = guild.get_role(pos_role_id) + if pos_role: + roles.append(pos_role) + except (ValueError, TypeError) as e: + logger.error(f"Error processing HOF position: {e}") + return roles + + +def _get_position_tier(position: int) -> Optional[str]: + """Get position tier based on HOF position.""" + if position == 1: + return "1" + elif position <= 10: + return "10" + return None + + +def _process_creator_roles(htb_user_content: dict, guild: Guild) -> list[Role]: + """Process creator role assignments.""" + roles = [] + try: + if htb_user_content.get("machines"): + box_creator_role = guild.get_role(settings.roles.BOX_CREATOR) + if box_creator_role: + logger.debug("Adding box creator role to user.") + roles.append(box_creator_role) + + if htb_user_content.get("challenges"): + challenge_creator_role = guild.get_role(settings.roles.CHALLENGE_CREATOR) + if challenge_creator_role: + logger.debug("Adding challenge creator role to user.") + roles.append(challenge_creator_role) + except Exception as e: + logger.error(f"Error processing creator roles: {e}") + return roles + + +async def _apply_role_changes( + member: Member, to_remove: list[Role], to_assign: list[Role] +) -> None: + """Apply role changes to member.""" + try: + if to_remove: + await member.remove_roles(*to_remove, atomic=True) + except Exception as e: + logger.error(f"Error removing roles from user {member.id}: {e}") + + try: + if to_assign: + await member.add_roles(*to_assign, atomic=True) + except Exception as e: + logger.error(f"Error adding roles to user {member.id}: {e}") From 3a6b8c491a72ea4a2cfefe6f27ef02c945b48aaf Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Wed, 16 Jul 2025 23:39:46 -0400 Subject: [PATCH 25/29] =?UTF-8?q?=E2=9C=A8=20Add=20AcademyHandler=20for=20?= =?UTF-8?q?processing=20certificate=20awarded=20events=20and=20refactor=20?= =?UTF-8?q?AccountHandler=20for=20improved=20property=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/academy.py | 50 +++++++++++++++++++++++ src/webhooks/handlers/account.py | 68 +++++++++++++++++++++++++------- src/webhooks/handlers/mp.py | 51 +++++------------------- 3 files changed, 114 insertions(+), 55 deletions(-) create mode 100644 src/webhooks/handlers/academy.py diff --git a/src/webhooks/handlers/academy.py b/src/webhooks/handlers/academy.py new file mode 100644 index 0000000..9e52ea5 --- /dev/null +++ b/src/webhooks/handlers/academy.py @@ -0,0 +1,50 @@ +from discord import Bot + +from src.core import settings +from src.webhooks.handlers.base import BaseHandler +from src.webhooks.types import WebhookBody, WebhookEvent + + +class AcademyHandler(BaseHandler): + async def handle(self, body: WebhookBody, bot: Bot): + """ + Handles incoming webhook events and performs actions accordingly. + + This function processes different webhook events originating from the + HTB Account. + """ + if body.event == WebhookEvent.CERTIFICATE_AWARDED: + return await self.handle_certificate_awarded(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")) + certificate_id = self.validate_property( + self.get_property_or_trait(body, "certificate_id"), "certificate_id" + ) + + self.logger.info(f"Handling certificate awarded event for {discord_id} with certificate {certificate_id}") + + member = await self.get_guild_member(discord_id, bot) + certificate_role_id = settings.get_academy_cert_role(int(certificate_id)) + + if not certificate_role_id: + self.logger.warning(f"No certificate role found for certificate {certificate_id}") + return self.fail() + + if certificate_role_id: + self.logger.info(f"Adding certificate role {certificate_role_id} to member {member.id}") + try: + await member.add_roles( + bot.guilds[0].get_role(certificate_role_id), atomic=True # type: ignore + ) # type: ignore + except Exception as e: + self.logger.error(f"Error adding certificate role {certificate_role_id} to member {member.id}: {e}") + raise e + + return self.success() diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index b69d6a7..f99dbd1 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -31,8 +31,12 @@ async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account linked event. """ - discord_id = self.validate_discord_id(body.properties.get("discord_id")) - account_id = self.validate_account_id(body.properties.get("account_id")) + 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") + ) member = await self.get_guild_member(discord_id, bot) await process_account_identification( @@ -40,9 +44,21 @@ async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: bot, # type: ignore traits=self.merge_properties_and_traits(body.properties, body.traits), ) - await bot.guilds[0].get_channel(settings.channels.VERIFY_LOGS).send( # type: ignore - f"Account linked: {account_id} -> ({member.mention} ({member.id})", - ) + + # Safely attempt to send verification log + try: + verify_channel = bot.guilds[0].get_channel(settings.channels.VERIFY_LOGS) + if verify_channel: + await verify_channel.send( # type: ignore + f"Account linked: {account_id} -> ({member.mention} ({member.id})", + ) + else: + self.logger.warning( + f"Verify logs channel {settings.channels.VERIFY_LOGS} not found" + ) + except Exception as e: + self.logger.error(f"Failed to send verification log: {e}") + # Don't raise - this is not critical self.logger.info( f"Account {account_id} linked to {member.id}", @@ -55,29 +71,51 @@ async def handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account unlinked event. """ - discord_id = self.validate_discord_id(body.properties.get("discord_id")) - account_id = self.validate_account_id(body.properties.get("account_id")) + 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") + ) member = await self.get_guild_member(discord_id, bot) - await member.remove_roles(bot.guilds[0].get_role(settings.roles.VERIFIED), atomic=True) # type: ignore + await member.remove_roles( + bot.guilds[0].get_role(settings.roles.VERIFIED), atomic=True # type: ignore + ) # type: ignore return self.success() + async def name_change(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the name change event. + """ + discord_id = self.validate_discord_id(body.properties.get("discord_id")) + _ = self.validate_account_id(body.properties.get("account_id")) + name = self.validate_property(body.properties.get("name"), "name") + + member = await self.get_guild_member(discord_id, bot) + await member.edit(nick=name) + return self.success() + async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account banned event. """ - discord_id = self.validate_discord_id(body.properties.get("discord_id")) - account_id = self.validate_account_id(body.properties.get("account_id")) + 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") + ) expires_at = self.validate_property( - body.properties.get("expires_at"), "expires_at" + self.get_property_or_trait(body, "expires_at"), "expires_at" ) reason = body.properties.get("reason") notes = body.properties.get("notes") created_by = body.properties.get("created_by") - expires_ts = int(datetime.fromisoformat(expires_at).timestamp()) + expires_ts = int(datetime.fromisoformat(expires_at).timestamp()) # type: ignore extra = {"account_id": account_id, "discord_id": discord_id} member = await self.get_guild_member(discord_id, bot) @@ -96,7 +134,7 @@ async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: reason=f"Platform Ban - {reason}", evidence=notes or "N/A", author_name=created_by or "System", - expires_at_str=expires_at, + expires_at_str=expires_at, # type: ignore log_channel_id=settings.channels.BOT_LOGS, logger=self.logger, extra_log_data=extra, @@ -123,6 +161,8 @@ async def handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: ) return self.fail() - await member.remove_roles(bot.guilds[0].get_role(settings.roles.VERIFIED), atomic=True) # type: ignore + await member.remove_roles( + bot.guilds[0].get_role(settings.roles.VERIFIED), atomic=True # type: ignore + ) # type: ignore return self.success() diff --git a/src/webhooks/handlers/mp.py b/src/webhooks/handlers/mp.py index 8958554..d4fce8c 100644 --- a/src/webhooks/handlers/mp.py +++ b/src/webhooks/handlers/mp.py @@ -19,35 +19,15 @@ async def handle(self, body: WebhookBody, bot: Bot): This function processes different webhook events originating from the HTB Account. """ - if body.event == WebhookEvent.NAME_CHANGE: - return await self.name_change(body, bot) - elif body.event == WebhookEvent.HOF_CHANGE: + if body.event == WebhookEvent.HOF_CHANGE: return await self.handle_hof_change(body, bot) elif body.event == WebhookEvent.RANK_UP: return await self.handle_rank_up(body, bot) elif body.event == WebhookEvent.SUBSCRIPTION_CHANGE: return await self.handle_subscription_change(body, bot) - elif body.event == WebhookEvent.CERTIFICATE_AWARDED: - return await self.handle_certificate_awarded(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(body.properties.get("discord_id")) - _ = self.validate_account_id(body.properties.get("account_id")) - certificate_id = self.validate_property(body.properties.get("certificate_id"), "certificate_id") - - member = await self.get_guild_member(discord_id, bot) - certificate_role_id = settings.get_academy_cert_role(int(certificate_id)) - - if certificate_role_id: - await member.add_roles(bot.guilds[0].get_role(certificate_role_id), atomic=True) # type: ignore - - return self.success() - async def handle_subscription_change(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the subscription change event. @@ -67,26 +47,15 @@ async def handle_subscription_change(self, body: WebhookBody, bot: Bot) -> dict: await member.add_roles(bot.guilds[0].get_role(role), atomic=True) # type: ignore return self.success() - async def name_change(self, body: WebhookBody, bot: Bot) -> dict: - """ - Handles the name change event. - """ - discord_id = self.validate_discord_id(body.properties.get("discord_id")) - _ = self.validate_account_id(body.properties.get("account_id")) - name = self.validate_property(body.properties.get("name"), "name") - - member = await self.get_guild_member(discord_id, bot) - await member.edit(nick=name) - return self.success() - async def handle_hof_change(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the HOF change event. """ - discord_id = self.validate_discord_id(body.properties.get("discord_id")) - account_id = self.validate_account_id(body.properties.get("account_id")) + self.logger.info("Handling HOF change 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")) hof_tier: Literal["1", "10"] = self.validate_property( - body.properties.get("hof_tier"), "hof_tier" + self.get_property_or_trait(body, "hof_tier"), "hof_tier" # type: ignore ) hof_roles = { "1": bot.guilds[0].get_role(settings.roles.RANK_ONE), @@ -115,7 +84,7 @@ async def _swap_hof_roles(member: Member, role_to_grant: Role | None): await member.remove_roles(member_hof_role, atomic=True) await member.add_roles(role_to_grant, atomic=True) - if hof_tier == "1": + if int(hof_tier) == 1: # Find existing top 1 user and make them a top 10 if existing_top_one_user := await self._find_user_with_role( bot, hof_roles["1"] @@ -130,7 +99,7 @@ async def _swap_hof_roles(member: Member, role_to_grant: Role | None): return self.success() # Just grant top 10 role to member - elif hof_tier == "10": + elif int(hof_tier) == 10: await _swap_hof_roles(member, hof_roles["10"]) return self.success() @@ -150,9 +119,9 @@ async def handle_rank_up(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the rank up event. """ - discord_id = self.validate_discord_id(body.properties.get("discord_id")) - account_id = self.validate_account_id(body.properties.get("account_id")) - rank = self.validate_property(body.properties.get("rank"), "rank") + 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")) + rank = self.validate_property(self.get_property_or_trait(body, "rank"), "rank") member = await self.get_guild_member(discord_id, bot) From 94d5b16b4d995dd7726cdc961ede2cbb3820a547 Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Wed, 16 Jul 2025 23:40:04 -0400 Subject: [PATCH 26/29] =?UTF-8?q?=F0=9F=90=9B=20Enhance=20webhook=20error?= =?UTF-8?q?=20handling=20with=20detailed=20logging=20and=20generic=20500?= =?UTF-8?q?=20response=20for=20unhandled=20exceptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/server.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/webhooks/server.py b/src/webhooks/server.py index a367118..1c4d9e3 100644 --- a/src/webhooks/server.py +++ b/src/webhooks/server.py @@ -75,7 +75,25 @@ async def webhook_handler(request: Request) -> Dict[str, Any]: logger.warning("Webhook request not handled by platform: %s", body.platform) raise HTTPException(status_code=501, detail="Platform not implemented") - return await handlers.handle(body, bot) + try: + return await handlers.handle(body, bot) + except HTTPException: + # Re-raise HTTP exceptions as they already have appropriate status codes + raise + except Exception as e: + # Log the full exception details for debugging + logger.error( + "Unhandled exception in webhook handler", + exc_info=e, + extra={ + "platform": body.platform, + "event": body.event, + "properties": body.properties, + "traits": body.traits, + } + ) + # Return a generic 500 error to the client + raise HTTPException(status_code=500, detail="Internal server error") app.mount("/metrics", metrics_app) From f0655afaebc4f4655d1acadf69979f671579661b Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Wed, 16 Jul 2025 23:40:32 -0400 Subject: [PATCH 27/29] =?UTF-8?q?=E2=9C=A8=20Add=20new=20handlers=20to=20h?= =?UTF-8?q?andler=20dict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/webhooks/handlers/__init__.py b/src/webhooks/handlers/__init__.py index 4ea73f1..4036462 100644 --- a/src/webhooks/handlers/__init__.py +++ b/src/webhooks/handlers/__init__.py @@ -2,10 +2,15 @@ from typing import Any from src.webhooks.handlers.account import AccountHandler +from src.webhooks.handlers.academy import AcademyHandler from src.webhooks.handlers.mp import MPHandler from src.webhooks.types import Platform, WebhookBody -handlers = {Platform.ACCOUNT: AccountHandler().handle, Platform.MAIN: MPHandler().handle} +handlers = { + Platform.ACCOUNT: AccountHandler().handle, + Platform.MAIN: MPHandler().handle, + Platform.ACADEMY: AcademyHandler().handle, +} def can_handle(platform: Platform) -> bool: From 4376e7c4d6378c27b321ba69da1d7d082de3486d Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Wed, 16 Jul 2025 23:42:43 -0400 Subject: [PATCH 28/29] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Make=20handler=20met?= =?UTF-8?q?hods=20private?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/academy.py | 4 ++-- src/webhooks/handlers/account.py | 18 +++++++++--------- src/webhooks/handlers/mp.py | 12 ++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/webhooks/handlers/academy.py b/src/webhooks/handlers/academy.py index 9e52ea5..a57785c 100644 --- a/src/webhooks/handlers/academy.py +++ b/src/webhooks/handlers/academy.py @@ -14,11 +14,11 @@ async def handle(self, body: WebhookBody, bot: Bot): HTB Account. """ if body.event == WebhookEvent.CERTIFICATE_AWARDED: - return await self.handle_certificate_awarded(body, bot) + return await self._handle_certificate_awarded(body, bot) else: raise ValueError(f"Invalid event: {body.event}") - async def handle_certificate_awarded(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_certificate_awarded(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the certificate awarded event. """ diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index f99dbd1..d941f8b 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -17,17 +17,17 @@ async def handle(self, body: WebhookBody, bot: Bot): HTB Account. """ if body.event == WebhookEvent.ACCOUNT_LINKED: - return await self.handle_account_linked(body, bot) + return await self._handle_account_linked(body, bot) elif body.event == WebhookEvent.ACCOUNT_UNLINKED: - return await self.handle_account_unlinked(body, bot) + return await self._handle_account_unlinked(body, bot) elif body.event == WebhookEvent.ACCOUNT_DELETED: - return await self.handle_account_deleted(body, bot) + return await self._handle_account_deleted(body, bot) elif body.event == WebhookEvent.ACCOUNT_BANNED: - return await self.handle_account_banned(body, bot) + return await self._handle_account_banned(body, bot) else: raise ValueError(f"Invalid event: {body.event}") - async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account linked event. """ @@ -67,7 +67,7 @@ async def handle_account_linked(self, body: WebhookBody, bot: Bot) -> dict: return self.success() - async def handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account unlinked event. """ @@ -86,7 +86,7 @@ async def handle_account_unlinked(self, body: WebhookBody, bot: Bot) -> dict: return self.success() - async def name_change(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_name_change(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the name change event. """ @@ -98,7 +98,7 @@ async def name_change(self, body: WebhookBody, bot: Bot) -> dict: await member.edit(nick=name) return self.success() - async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account banned event. """ @@ -146,7 +146,7 @@ async def handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: return self.success() - async def handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account deleted event. """ diff --git a/src/webhooks/handlers/mp.py b/src/webhooks/handlers/mp.py index d4fce8c..59888b4 100644 --- a/src/webhooks/handlers/mp.py +++ b/src/webhooks/handlers/mp.py @@ -20,15 +20,15 @@ async def handle(self, body: WebhookBody, bot: Bot): HTB Account. """ if body.event == WebhookEvent.HOF_CHANGE: - return await self.handle_hof_change(body, bot) + return await self._handle_hof_change(body, bot) elif body.event == WebhookEvent.RANK_UP: - return await self.handle_rank_up(body, bot) + return await self._handle_rank_up(body, bot) elif body.event == WebhookEvent.SUBSCRIPTION_CHANGE: - return await self.handle_subscription_change(body, bot) + return await self._handle_subscription_change(body, bot) else: raise ValueError(f"Invalid event: {body.event}") - async def handle_subscription_change(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_subscription_change(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the subscription change event. """ @@ -47,7 +47,7 @@ async def handle_subscription_change(self, body: WebhookBody, bot: Bot) -> dict: await member.add_roles(bot.guilds[0].get_role(role), atomic=True) # type: ignore return self.success() - async def handle_hof_change(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_hof_change(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the HOF change event. """ @@ -115,7 +115,7 @@ async def _swap_hof_roles(member: Member, role_to_grant: Role | None): ) raise err - async def handle_rank_up(self, body: WebhookBody, bot: Bot) -> dict: + async def _handle_rank_up(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the rank up event. """ From 4138d0534cf5fdabf30ae259c9ed8726b152630f Mon Sep 17 00:00:00 2001 From: 0xry4n Date: Wed, 16 Jul 2025 23:56:41 -0400 Subject: [PATCH 29/29] =?UTF-8?q?=E2=9C=85=20Fix=20tests,=20bug=20fix=20MP?= =?UTF-8?q?=20rank=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webhooks/handlers/mp.py | 16 +- tests/src/helpers/test_verification.py | 37 +++- tests/src/webhooks/handlers/test_academy.py | 119 +++++++++++ tests/src/webhooks/handlers/test_account.py | 137 +++++++++++- tests/src/webhooks/handlers/test_mp.py | 223 ++++++++++++++++++++ 5 files changed, 514 insertions(+), 18 deletions(-) create mode 100644 tests/src/webhooks/handlers/test_academy.py create mode 100644 tests/src/webhooks/handlers/test_mp.py diff --git a/src/webhooks/handlers/mp.py b/src/webhooks/handlers/mp.py index 59888b4..c8b0e2b 100644 --- a/src/webhooks/handlers/mp.py +++ b/src/webhooks/handlers/mp.py @@ -125,11 +125,25 @@ async def _handle_rank_up(self, body: WebhookBody, bot: Bot) -> dict: member = await self.get_guild_member(discord_id, bot) + rank_id = settings.get_post_or_rank(rank) + if not rank_id: + err = ValueError(f"Cannot find role for '{rank}'") + self.logger.error( + err, + extra={ + "account_id": account_id, + "discord_id": discord_id, + "rank": rank, + }, + ) + raise err + + rank_role = bot.guilds[0].get_role(rank_id) rank_roles = [ bot.guilds[0].get_role(int(r)) for r in settings.role_groups["ALL_RANKS"] ] # All rank roles new_role = next( - (r for r in rank_roles if r and r.name == rank), None + (r for r in rank_roles if r and r.id == rank_role.id), None ) # Get passed rank as role from rank roles old_role = next( (r for r in member.roles if r in rank_roles), None diff --git a/tests/src/helpers/test_verification.py b/tests/src/helpers/test_verification.py index 3ebddf0..1c949da 100644 --- a/tests/src/helpers/test_verification.py +++ b/tests/src/helpers/test_verification.py @@ -14,37 +14,60 @@ async def test_get_user_details_success(self): account_identifier = "some_identifier" with aioresponses.aioresponses() as m: + # Mock the profile API call m.get( - f"{settings.API_URL}/discord/identifier/{account_identifier}?secret={settings.HTB_API_SECRET}", + f"{settings.API_V4_URL}/user/profile/basic/{account_identifier}", status=200, - payload={"some_key": "some_value"}, + payload={"profile": {"some_key": "some_value"}}, + ) + # Mock the content API call + m.get( + f"{settings.API_V4_URL}/user/profile/content/{account_identifier}", + status=200, + payload={"profile": {"content": {"content_key": "content_value"}}}, ) result = await get_user_details(account_identifier) - self.assertEqual(result, {"some_key": "some_value"}) + expected = { + "some_key": "some_value", + "content": {"content_key": "content_value"} + } + self.assertEqual(result, expected) @pytest.mark.asyncio async def test_get_user_details_404(self): account_identifier = "some_identifier" with aioresponses.aioresponses() as m: + # Mock the profile API call with404 m.get( - f"{settings.API_URL}/discord/identifier/{account_identifier}?secret={settings.HTB_API_SECRET}", + f"{settings.API_V4_URL}/user/profile/basic/{account_identifier}", + status=404, + ) + # Mock the content API call with404 + m.get( + f"{settings.API_V4_URL}/user/profile/content/{account_identifier}", status=404, ) result = await get_user_details(account_identifier) - self.assertIsNone(result) + self.assertEqual(result, {"content": {}}) @pytest.mark.asyncio async def test_get_user_details_other_status(self): account_identifier = "some_identifier" with aioresponses.aioresponses() as m: + # Mock the profile API call with500 + m.get( + f"{settings.API_V4_URL}/user/profile/basic/{account_identifier}", + status=500, + ) + # Mock the content API call with500 m.get( - f"{settings.API_URL}/discord/identifier/{account_identifier}?secret={settings.HTB_API_SECRET}", + f"{settings.API_V4_URL}/user/profile/content/{account_identifier}", status=500, ) result = await get_user_details(account_identifier) - self.assertIsNone(result) + self.assertEqual(result, {"content": {}}) diff --git a/tests/src/webhooks/handlers/test_academy.py b/tests/src/webhooks/handlers/test_academy.py new file mode 100644 index 0000000..2c76af4 --- /dev/null +++ b/tests/src/webhooks/handlers/test_academy.py @@ -0,0 +1,119 @@ +import pytest +from unittest.mock import AsyncMock, patch +from fastapi import HTTPException + +from src.webhooks.handlers.academy import AcademyHandler +from src.webhooks.types import WebhookBody, Platform, WebhookEvent +from tests import helpers + +class TestAcademyHandler: + @pytest.mark.asyncio + async def test_handle_certificate_awarded_success(self, bot): + handler = AcademyHandler() + discord_id = 123456789 + account_id = 987654321 + certificate_id = 42 + mock_member = helpers.MockMember(id=discord_id) + mock_member.add_roles = AsyncMock() + body = WebhookBody( + platform=Platform.ACADEMY, + event=WebhookEvent.CERTIFICATE_AWARDED, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "certificate_id": certificate_id, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=certificate_id), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.academy.settings") as mock_settings, + patch.object(handler.logger, "info") as mock_log, + ): + mock_settings.get_academy_cert_role.return_value = 555 + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.return_value = 555 + bot.guilds = [mock_guild] + result = await handler._handle_certificate_awarded(body, bot) + mock_member.add_roles.assert_awaited() + mock_log.assert_called() + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_certificate_awarded_no_role(self, bot): + handler = AcademyHandler() + discord_id = 123456789 + account_id = 987654321 + certificate_id = 42 + mock_member = helpers.MockMember(id=discord_id) + body = WebhookBody( + platform=Platform.ACADEMY, + event=WebhookEvent.CERTIFICATE_AWARDED, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "certificate_id": certificate_id, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=certificate_id), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.academy.settings") as mock_settings, + patch.object(handler.logger, "warning") as mock_log, + ): + mock_settings.get_academy_cert_role.return_value = None + result = await handler._handle_certificate_awarded(body, bot) + mock_log.assert_called() + assert result == handler.fail() + + @pytest.mark.asyncio + async def test_handle_certificate_awarded_add_roles_error(self, bot): + handler = AcademyHandler() + discord_id = 123456789 + account_id = 987654321 + certificate_id = 42 + mock_member = helpers.MockMember(id=discord_id) + mock_member.add_roles = AsyncMock(side_effect=Exception("add_roles error")) + body = WebhookBody( + platform=Platform.ACADEMY, + event=WebhookEvent.CERTIFICATE_AWARDED, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "certificate_id": certificate_id, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=certificate_id), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.academy.settings") as mock_settings, + patch.object(handler.logger, "error") as mock_log, + ): + mock_settings.get_academy_cert_role.return_value = 555 + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.return_value = 555 + bot.guilds = [mock_guild] + with pytest.raises(Exception, match="add_roles error"): + await handler._handle_certificate_awarded(body, bot) + mock_log.assert_called() + + @pytest.mark.asyncio + async def test_handle_invalid_event(self, bot): + handler = AcademyHandler() + body = WebhookBody( + platform=Platform.ACADEMY, + event=WebhookEvent.RANK_UP, # Not handled by AcademyHandler + properties={}, + traits={}, + ) + with pytest.raises(ValueError, match="Invalid event"): + await handler.handle(body, bot) \ No newline at end of file diff --git a/tests/src/webhooks/handlers/test_account.py b/tests/src/webhooks/handlers/test_account.py index a103b31..e18cf7e 100644 --- a/tests/src/webhooks/handlers/test_account.py +++ b/tests/src/webhooks/handlers/test_account.py @@ -33,7 +33,7 @@ async def test_handle_account_linked_event(self, bot): ) with patch.object( - handler, "handle_account_linked", new_callable=AsyncMock + handler, "_handle_account_linked", new_callable=AsyncMock ) as mock_handle: await handler.handle(body, bot) mock_handle.assert_called_once_with(body, bot) @@ -50,7 +50,7 @@ async def test_handle_account_unlinked_event(self, bot): ) with patch.object( - handler, "handle_account_unlinked", new_callable=AsyncMock + handler, "_handle_account_unlinked", new_callable=AsyncMock ) as mock_handle: await handler.handle(body, bot) mock_handle.assert_called_once_with(body, bot) @@ -152,7 +152,7 @@ async def test_handle_account_linked_success(self, bot): mock_guild.get_channel.return_value = mock_channel bot.guilds = [mock_guild] - result = await handler.handle_account_linked(body, bot) + result = await handler._handle_account_linked(body, bot) # Verify all method calls mock_validate_discord.assert_called_once_with(discord_id) @@ -197,7 +197,7 @@ async def test_handle_account_linked_invalid_discord_id(self, bot): side_effect=HTTPException(status_code=400, detail="Invalid Discord ID"), ): with pytest.raises(HTTPException) as exc_info: - await handler.handle_account_linked(body, bot) + await handler._handle_account_linked(body, bot) assert exc_info.value.status_code == 400 assert exc_info.value.detail == "Invalid Discord ID" @@ -223,7 +223,7 @@ async def test_handle_account_linked_invalid_account_id(self, bot): ), ): with pytest.raises(HTTPException) as exc_info: - await handler.handle_account_linked(body, bot) + await handler._handle_account_linked(body, bot) assert exc_info.value.status_code == 400 assert exc_info.value.detail == "Invalid Account ID" @@ -255,7 +255,7 @@ async def test_handle_account_linked_user_not_in_guild(self, bot): ), ): with pytest.raises(HTTPException) as exc_info: - await handler.handle_account_linked(body, bot) + await handler._handle_account_linked(body, bot) assert exc_info.value.status_code == 400 assert exc_info.value.detail == "User is not in the Discord server" @@ -298,7 +298,7 @@ async def test_handle_account_unlinked_success(self, bot): mock_settings.roles.VERIFIED = 99999 mock_member.remove_roles = AsyncMock() - result = await handler.handle_account_unlinked(body, bot) + result = await handler._handle_account_unlinked(body, bot) # Verify all method calls mock_validate_discord.assert_called_once_with(discord_id) @@ -329,7 +329,7 @@ async def test_handle_account_unlinked_invalid_discord_id(self, bot): side_effect=HTTPException(status_code=400, detail="Invalid Discord ID"), ): with pytest.raises(HTTPException) as exc_info: - await handler.handle_account_unlinked(body, bot) + await handler._handle_account_unlinked(body, bot) assert exc_info.value.status_code == 400 assert exc_info.value.detail == "Invalid Discord ID" @@ -355,7 +355,7 @@ async def test_handle_account_unlinked_invalid_account_id(self, bot): ), ): with pytest.raises(HTTPException) as exc_info: - await handler.handle_account_unlinked(body, bot) + await handler._handle_account_unlinked(body, bot) assert exc_info.value.status_code == 400 assert exc_info.value.detail == "Invalid Account ID" @@ -387,7 +387,124 @@ async def test_handle_account_unlinked_user_not_in_guild(self, bot): ), ): with pytest.raises(HTTPException) as exc_info: - await handler.handle_account_unlinked(body, bot) + await handler._handle_account_unlinked(body, bot) assert exc_info.value.status_code == 400 assert exc_info.value.detail == "User is not in the Discord server" + + @pytest.mark.asyncio + async def test_handle_name_change_success(self, bot): + """Test successful name change event.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + new_name = "NewNickname" + mock_member = helpers.MockMember(id=discord_id) + mock_member.edit = AsyncMock() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.NAME_CHANGE, + properties={"discord_id": discord_id, "account_id": account_id, "name": new_name}, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=new_name), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + ): + result = await handler._handle_name_change(body, bot) + mock_member.edit.assert_called_once_with(nick=new_name) + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_name_change_invalid_discord_id(self, bot): + """Test name change event with invalid Discord ID.""" + handler = AccountHandler() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.NAME_CHANGE, + properties={"discord_id": None, "account_id": 987654321, "name": "NewNickname"}, + traits={}, + ) + with patch.object( + handler, + "validate_discord_id", + side_effect=HTTPException(status_code=400, detail="Invalid Discord ID"), + ): + with pytest.raises(HTTPException) as exc_info: + await handler._handle_name_change(body, bot) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid Discord ID" + + @pytest.mark.asyncio + async def test_handle_account_banned_success(self, bot): + """Test successful account banned event.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + expires_at = "2024-12-31T23:59:59" + reason = "Violation" + notes = "Repeated violations" + created_by = "Admin" + mock_member = helpers.MockMember(id=discord_id) + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_BANNED, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "expires_at": expires_at, + "reason": reason, + "notes": notes, + "created_by": created_by, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=expires_at), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.account.handle_platform_ban_or_update", new_callable=AsyncMock) as mock_ban, + patch("src.webhooks.handlers.account.settings") as mock_settings, + patch.object(handler.logger, "debug") as mock_log, + ): + mock_ban.return_value = {"action": "banned"} + mock_settings.channels.BOT_LOGS = 12345 + mock_settings.channels.VERIFY_LOGS = 54321 + mock_settings.roles.VERIFIED = 99999 + mock_settings.guild_ids = [1] + bot.guilds = [helpers.MockGuild(id=1)] + result = await handler._handle_account_banned(body, bot) + mock_ban.assert_awaited() + mock_log.assert_called() + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_account_banned_member_not_found(self, bot): + """Test account banned event when member is not found in guild.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + expires_at = "2024-12-31T23:59:59" + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.ACCOUNT_BANNED, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "expires_at": expires_at, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=expires_at), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=None), + patch.object(handler.logger, "warning") as mock_log, + ): + result = await handler._handle_account_banned(body, bot) + mock_log.assert_called() + assert result == handler.fail() diff --git a/tests/src/webhooks/handlers/test_mp.py b/tests/src/webhooks/handlers/test_mp.py new file mode 100644 index 0000000..3454d1b --- /dev/null +++ b/tests/src/webhooks/handlers/test_mp.py @@ -0,0 +1,223 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi import HTTPException + +from src.webhooks.handlers.mp import MPHandler +from src.webhooks.types import WebhookBody, Platform, WebhookEvent +from tests import helpers + +class TestMPHandler: + @pytest.mark.asyncio + async def test_handle_invalid_event(self, bot): + handler = MPHandler() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.CERTIFICATE_AWARDED, # Not handled by MPHandler + properties={}, + traits={}, + ) + with pytest.raises(ValueError, match="Invalid event"): + await handler.handle(body, bot) + + @pytest.mark.asyncio + async def test_handle_subscription_change_success(self, bot): + handler = MPHandler() + discord_id = 123456789 + account_id = 987654321 + subscription_name = "VIP" + mock_member = helpers.MockMember(id=discord_id) + mock_member.add_roles = AsyncMock() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.SUBSCRIPTION_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "subscription_name": subscription_name, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=subscription_name), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.mp.settings") as mock_settings, + ): + mock_settings.get_post_or_rank.return_value = 555 + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.return_value = 555 + bot.guilds = [mock_guild] + result = await handler._handle_subscription_change(body, bot) + mock_member.add_roles.assert_awaited() + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_subscription_change_invalid_role(self, bot): + handler = MPHandler() + discord_id = 123456789 + account_id = 987654321 + subscription_name = "INVALID" + mock_member = helpers.MockMember(id=discord_id) + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.SUBSCRIPTION_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "subscription_name": subscription_name, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=subscription_name), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.mp.settings") as mock_settings, + ): + mock_settings.get_post_or_rank.return_value = None + with pytest.raises(ValueError, match="Invalid subscription name"): + await handler._handle_subscription_change(body, bot) + + @pytest.mark.asyncio + async def test_handle_hof_change_success_top1(self, bot): + handler = MPHandler() + discord_id = 123456789 + account_id = 987654321 + hof_tier = "1" + mock_member = helpers.MockMember(id=discord_id) + mock_member.roles = [] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.HOF_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "hof_tier": hof_tier, + }, + traits={}, + ) + mock_role_1 = MagicMock() + mock_role_10 = MagicMock() + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=hof_tier), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.mp.settings") as mock_settings, + patch.object(handler, "_find_user_with_role", new_callable=AsyncMock, return_value=None), + ): + mock_settings.roles.RANK_ONE = 1 + mock_settings.roles.RANK_TEN = 10 + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.side_effect = lambda rid: mock_role_1 if rid == 1 else mock_role_10 + bot.guilds = [mock_guild] + result = await handler._handle_hof_change(body, bot) + mock_member.add_roles.assert_awaited_with(mock_role_1, atomic=True) + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_hof_change_invalid_tier(self, bot): + handler = MPHandler() + discord_id = 123456789 + account_id = 987654321 + hof_tier = "99" + mock_member = helpers.MockMember(id=discord_id) + mock_member.roles = [] + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.HOF_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "hof_tier": hof_tier, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=hof_tier), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.mp.settings") as mock_settings, + ): + mock_settings.roles.RANK_ONE = 1 + mock_settings.roles.RANK_TEN = 10 + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.side_effect = lambda rid: None + bot.guilds = [mock_guild] + with pytest.raises(ValueError, match="Invalid HOF tier"): + await handler._handle_hof_change(body, bot) + + @pytest.mark.asyncio + async def test_handle_rank_up_success(self, bot): + handler = MPHandler() + discord_id = 123456789 + account_id = 987654321 + rank = "Elite Hacker" + mock_member = helpers.MockMember(id=discord_id) + mock_member.roles = [] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.RANK_UP, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "rank": rank, + }, + traits={}, + ) + mock_role = MagicMock() + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=rank), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.mp.settings") as mock_settings, + ): + mock_settings.role_groups = {"ALL_RANKS": [555]} + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.return_value = mock_role + bot.guilds = [mock_guild] + result = await handler._handle_rank_up(body, bot) + mock_member.add_roles.assert_awaited_with(mock_role, atomic=True) + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_rank_up_invalid_role(self, bot): + handler = MPHandler() + discord_id = 123456789 + account_id = 987654321 + rank = "Nonexistent" + mock_member = helpers.MockMember(id=discord_id) + mock_member.roles = [] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.MAIN, + event=WebhookEvent.RANK_UP, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "rank": rank, + }, + traits={}, + ) + with ( + patch.object(handler, "validate_discord_id", return_value=discord_id), + patch.object(handler, "validate_account_id", return_value=account_id), + patch.object(handler, "validate_property", return_value=rank), + patch.object(handler, "get_guild_member", new_callable=AsyncMock, return_value=mock_member), + patch("src.webhooks.handlers.mp.settings") as mock_settings, + ): + mock_settings.role_groups = {"ALL_RANKS": [555]} + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.return_value = None + bot.guilds = [mock_guild] + with pytest.raises(ValueError, match="Cannot find role for"): + await handler._handle_rank_up(body, bot) \ No newline at end of file