From 4aa4acda4cdfcf03027175b116cc2fe4fd5ca2b8 Mon Sep 17 00:00:00 2001 From: ewan barnett Date: Sat, 30 Aug 2025 14:42:15 +0100 Subject: [PATCH 01/39] I'M COMMITTED!!!! --- cogs/__init__.py | 4 +- cogs/make_member.py | 113 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/cogs/__init__.py b/cogs/__init__.py index beca94749..39ddac705 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -35,7 +35,7 @@ from .invite_link import InviteLinkCommandCog from .kill import KillCommandCog from .make_applicant import MakeApplicantContextCommandsCog, MakeApplicantSlashCommandCog -from .make_member import MakeMemberCommandCog, MemberCountCommandCog +from .make_member import MakeMemberCommandCog, MemberCountCommandCog, MakeMemberModalCommandCog from .ping import PingCommandCog from .remind_me import ClearRemindersBacklogTaskCog, RemindMeCommandCog from .send_get_roles_reminders import SendGetRolesRemindersTaskCog @@ -75,6 +75,7 @@ "MakeApplicantContextCommandsCog", "MakeApplicantSlashCommandCog", "MakeMemberCommandCog", + "MakeMemberModalCommandCog", "ManualModerationCog", "MemberCountCommandCog", "PingCommandCog", @@ -118,6 +119,7 @@ def setup(bot: "TeXBot") -> None: MakeMemberCommandCog, ManualModerationCog, MemberCountCommandCog, + MakeMemberModalCommandCog, PingCommandCog, RemindMeCommandCog, SendGetRolesRemindersTaskCog, diff --git a/cogs/make_member.py b/cogs/make_member.py index 670d4d015..3060db3d8 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -2,18 +2,22 @@ import logging import re +import ssl from typing import TYPE_CHECKING import aiohttp import bs4 +import certifi import discord from bs4 import BeautifulSoup from django.core.exceptions import ValidationError +from discord.ui import View, Modal from config import settings from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError -from utils import GLOBAL_SSL_CONTEXT, CommandChecks, TeXBotBaseCog +from utils import CommandChecks, TeXBotBaseCog +from utils.message_sender_components import ResponseMessageSender if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -21,8 +25,9 @@ from typing import Final from utils import TeXBotApplicationContext + from utils.message_sender_components import MessageSavingSenderComponent -__all__: "Sequence[str]" = ("MakeMemberCommandCog", "MemberCountCommandCog") +__all__: "Sequence[str]" = ("MakeMemberCommandCog", "MemberCountCommandCog", "MakeMemberModalCommandCog") logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -156,13 +161,12 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st guild_member_ids: set[str] = set() + ssl_context: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) async with ( aiohttp.ClientSession( headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES ) as http_session, - http_session.get( - url=GROUPED_MEMBERS_URL, ssl=GLOBAL_SSL_CONTEXT - ) as http_response, + http_session.get(url=GROUPED_MEMBERS_URL, ssl=ssl_context) as http_response, ): response_html: str = await http_response.text() @@ -276,13 +280,12 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: await ctx.defer(ephemeral=False) async with ctx.typing(): + ssl_context: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) async with ( aiohttp.ClientSession( headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES ) as http_session, - http_session.get( - url=BASE_MEMBERS_URL, ssl=GLOBAL_SSL_CONTEXT - ) as http_response, + http_session.get(url=BASE_MEMBERS_URL, ssl=ssl_context) as http_response, ): response_html: str = await http_response.text() @@ -326,3 +329,97 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: len(member_table.find_all('tr', {'class': ['msl_row', 'msl_altrow']})) } members! :tada:" ) + +class MakeMemberModalActual(Modal): + """A discord.Modal containing a the input box for make member user interaction.""" + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.add_item(discord.ui.InputText(label="Student ID")) + + async def callback(self, interaction: discord.Interaction): + embed = discord.Embed(title="Modal Results") + embed.add_field(name="Short Input", value=self.children[0].value) + await interaction.response.send_message(embeds=[embed]) + + +class OpenMemberVerifyModalView(View): + """A discord.View containing a button to open a new member verification modal.""" + + @discord.ui.button( + label="Verify", style=discord.ButtonStyle.primary, custom_id="verify_new_member" + ) + async def verify_new_member_button_callback( # type: ignore[misc] + self, _: discord.Button, interaction: discord.Interaction + ) -> None: + + logger.debug('"Verify" button pressed. %s', interaction) + await interaction.response.send_modal(MakeMemberModalActual()) + + +class MakeMemberModalCommandCog(TeXBotBaseCog): + """Cog class that defines the "/make-member-modal" command and its call-back method.""" + + async def _open_make_new_member_modal( + self, + message_sender_component: "MessageSavingSenderComponent", + interaction_user: discord.User, + button_callback_channel: discord.TextChannel | discord.DMChannel, + ) -> None: + await message_sender_component.send( + content="would you like to open the make member modal", view=OpenMemberVerifyModalView() + ) + + button_interaction: discord.Interaction = await self.bot.wait_for( + "interaction", + check=lambda interaction: ( + interaction.type == discord.InteractionType.component + and interaction.user == interaction_user + and interaction.channel == button_callback_channel + and "custom_id" in interaction.data + and interaction.data["custom_id"] in {"verify_new_member"} + ), + ) + + if button_interaction.data["custom_id"] == "verify_new_member": # type: ignore[index, typeddict-item] + + await button_interaction.edit_original_response( + content=( + f"Successfully opend make member modal " + ), + view=None, + ) + + #modal activation + + + return + + raise ValueError + + @discord.slash_command( # type: ignore[no-untyped-call, misc] + name="make-member-modal", + description=( + "prints a message with a button that allows users to open the make member modal, " + ), + ) + + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def make_member_modal( + self, + ctx: "TeXBotApplicationContext", + ) -> None: # type: ignore[misc] + + """ + Definition & callback response of the "make-member-modal" command. + + The "make-member-modal" command prints a message with a button that allows users + to open the make member modal + """ + + await self._open_make_new_member_modal( + message_sender_component=ResponseMessageSender(ctx), + interaction_user=ctx.user, + button_callback_channel=ctx.channel, + ) From c5b3cd7156dad71aef1084baf62cd32a98b1bd2c Mon Sep 17 00:00:00 2001 From: ewan barnett Date: Sat, 30 Aug 2025 15:56:30 +0100 Subject: [PATCH 02/39] ERROR - (make_member_modal) MessageSavingSenderComponent.send() got an unexpected keyword argument 'ephemeral' --- .env.example | 122 -------------------------------------------- cogs/make_member.py | 20 +++----- 2 files changed, 8 insertions(+), 134 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index d69be8b3c..000000000 --- a/.env.example +++ /dev/null @@ -1,122 +0,0 @@ -# !!REQUIRED!! -# The Discord token for the bot you created (available on your bot page in the developer portal: https://discord.com/developers/applications)) -# Must be a valid Discord bot token (see https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts) -DISCORD_BOT_TOKEN=[Replace with your Discord bot token] - -# !!REQUIRED!! -# The ID of the your Discord guild -# Must be a valid Discord guild ID (see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id) -DISCORD_GUILD_ID=[Replace with the ID of the your Discord guild] - -# The webhook URL of the Discord text channel where error logs should be sent -# Error logs will always be sent to the console, this setting allows them to also be sent to a Discord log channel -# Must be a valid Discord channel webhook URL (see https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) -DISCORD_LOG_CHANNEL_WEBHOOK_URL=[Replace with your Discord log channel webhook URL] - -# The full name of your community group, do NOT use an abbreviation. -# This is substituted into many error/welcome messages sent into your Discord guild, by the bot. -# If this is not set the group-full-name will be retrieved from the name of your group's Discord guild -GROUP_NAME=[Replace with the full name of your community group (not an abbreviation)] - -# The short colloquial name of your community group, it is recommended that you set this to be an abbreviation of your group's name. -# If this is not set the group-short-name will be determined from your group's full name -GROUP_SHORT_NAME=[Replace with the short colloquial name of your community group] - -# The URL of the page where guests can purchase a full membership to join your community group -# Must be a valid URL -PURCHASE_MEMBERSHIP_URL=[Replace with your group\'s purchase-membership URL] - -# The URL of the page containing information about the perks of buying a full membership to join your community group -# Must be a valid URL -MEMBERSHIP_PERKS_URL=[Replace with your group\'s membership-perks URL] - -# The invite link URL to allow users to join your community group's Discord server -# Must be a valid URL -CUSTOM_DISCORD_INVITE_URL=[Replace with your group\'s Discord server invite link] - - -# The minimum level that logs must meet in order to be logged to the console output stream -# One of: DEBUG, INFO, WARNING, ERROR, CRITICAL -CONSOLE_LOG_LEVEL=INFO - - -# !!REQUIRED!! -# The URL to retrieve the list of IDs of people that have purchased a membership to your community group -# Ensure that all members are visible without pagination. For example, if your members-list is found on the UoB Guild of Students website, ensure the URL includes the "sort by groups" option -# Must be a valid URL -ORGANISATION_ID=[Replace with your group\'s MSL Organisation ID] - -# !!REQUIRED!! -# The cookie required for access to your Student Union's online platform. -# If your group's members-list is stored at a URL that requires authentication, this session cookie should authenticate the bot to view your group's members-list, as if it were logged in to the website as a Committee member -# This can be extracted from your web-browser, after logging in to view your members-list yourself. It will probably be listed as a cookie named `.ASPXAUTH` -SU_PLATFORM_ACCESS_COOKIE=[Replace with your .ASPXAUTH cookie] - - -# The probability that the more rare ping command response will be sent instead of the normal one -# Must be a float between & including 0 to 1 -PING_COMMAND_EASTER_EGG_PROBABILITY=0.01 - - -# The path to the messages JSON file that contains the common messages sent by the bot -# Must be a path to a JSON file that exists, that contains a JSON string that can be decoded into a Python dict object -MESSAGES_FILE_PATH=messages.json - - -# Whether introduction reminders will be sent to Discord members that are not inducted, saying that they need to send an introduction to be allowed access -# One of: Once, Interval, False -SEND_INTRODUCTION_REMINDERS=Once - -# How long to wait after a user joins your guild before sending them the first/only message remind them to send an introduction -# Is ignored if SEND_INTRODUCTION_REMINDERS=False -# Must be a string of the seconds, minutes, hours, days or weeks before the first/only reminder is sent (format: "smhdw") -# The delay must be longer than or equal to 1 day (in any allowed format) -SEND_INTRODUCTION_REMINDERS_DELAY=40h - -# The interval of time between sending out reminders to Discord members that are not inducted, saying that they need to send an introduction to be allowed access -# Is ignored if SEND_INTRODUCTION_REMINDERS=Once or SEND_INTRODUCTION_REMINDERS=False -# Must be a string of the seconds, minutes or hours between reminders (format: "smh") -SEND_INTRODUCTION_REMINDERS_INTERVAL=6h - -# Whether reminders will be sent to Discord members that have been inducted, saying that they can get opt-in roles. (This message will be only sent once per Discord member) -# Must be a boolean (True or False) -SEND_GET_ROLES_REMINDERS=True - -# How long to wait after a user is inducted before sending them the message to get some opt-in roles -# Is ignored if SEND_GET_ROLES_REMINDERS=False -# Must be a string of the seconds, minutes, hours, days or weeks before a reminder is sent (format: "smhdw") -# The delay must be longer than or equal to 1 day (in any allowed format) -SEND_GET_ROLES_REMINDERS_DELAY=40h - -# !!This is an advanced configuration variable, so is unlikely to need to be changed from its default value!! -# The interval of time between sending out reminders to Discord members that have been inducted, saying that they can get opt-in roles. (This message will be only sent once, the interval is just how often the check for new guests occurs) -# Is ignored if SEND_GET_ROLES_REMINDERS=False -# Must be a string of the seconds, minutes or hours between reminders (format: "smh") -ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL=24h - - -# The number of days to look over messages sent, to generate statistics data -# Must be a float representing the number of days to look back through -STATISTICS_DAYS=30 - -# The names of the roles to gather statistics about, to display in bar chart graphs -# Must be a comma seperated list of strings of role names -STATISTICS_ROLES=Committee,Committee-Elect,Student Rep,Member,Guest,Server Booster,Foundation Year,First Year,Second Year,Final Year,Year In Industry,Year Abroad,PGT,PGR,Alumnus/Alumna,Postdoc,Quiz Victor - - -# !!REQUIRED!! -# The URL of the your group's Discord guild moderation document -# Must be a valid URL -MODERATION_DOCUMENT_URL=[Replace with your group\'s moderation document URL] - - -# The name of the channel, that warning messages will be sent to when a committee-member manually applies a moderation action (instead of using the `/strike` command) -# Must be the name of a Discord channel in your group's Discord guild, or the value "DM" (which indicates that the messages will be sent in the committee-member's DMs) -# This can be the name of ANY Discord channel (so the offending person *will* be able to see these messages if a public channel is chosen) -MANUAL_MODERATION_WARNING_MESSAGE_LOCATION=DM - - -# The set of roles that are tied to the membership of your community group -# These roles will be removed along with the membership role upon annual handover/reset -# Must be a comma seperated list of strings of role names -MEMBERSHIP_DEPENDENT_ROLES=member-red,member-blue,member-green,member-yellow,member-purple,member-pink,member-orange,member-grey,member-black,member-white \ No newline at end of file diff --git a/cogs/make_member.py b/cogs/make_member.py index 3060db3d8..e356f6027 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -332,8 +332,9 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: class MakeMemberModalActual(Modal): """A discord.Modal containing a the input box for make member user interaction.""" - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) + + def __init__(self) -> None: + super().__init__(title="Make Member Modal") self.add_item(discord.ui.InputText(label="Student ID")) @@ -367,7 +368,9 @@ async def _open_make_new_member_modal( button_callback_channel: discord.TextChannel | discord.DMChannel, ) -> None: await message_sender_component.send( - content="would you like to open the make member modal", view=OpenMemberVerifyModalView() + content="would you like to open the make member modal", + view=OpenMemberVerifyModalView(), + ephemeral=False ) button_interaction: discord.Interaction = await self.bot.wait_for( @@ -383,16 +386,9 @@ async def _open_make_new_member_modal( if button_interaction.data["custom_id"] == "verify_new_member": # type: ignore[index, typeddict-item] - await button_interaction.edit_original_response( - content=( - f"Successfully opend make member modal " - ), - view=None, + await button_interaction.message.reply( + content="you have opened this modal once before", ) - - #modal activation - - return raise ValueError From d025817cf6703f34170ad4a14cc01a769627055c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 30 Aug 2025 14:59:16 +0000 Subject: [PATCH 03/39] [pre-commit.ci lite] apply automatic fixes --- cogs/make_member.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index e356f6027..f82521141 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -27,7 +27,11 @@ from utils import TeXBotApplicationContext from utils.message_sender_components import MessageSavingSenderComponent -__all__: "Sequence[str]" = ("MakeMemberCommandCog", "MemberCountCommandCog", "MakeMemberModalCommandCog") +__all__: "Sequence[str]" = ( + "MakeMemberCommandCog", + "MemberCountCommandCog", + "MakeMemberModalCommandCog", +) logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -330,6 +334,7 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: } members! :tada:" ) + class MakeMemberModalActual(Modal): """A discord.Modal containing a the input box for make member user interaction.""" @@ -353,7 +358,6 @@ class OpenMemberVerifyModalView(View): async def verify_new_member_button_callback( # type: ignore[misc] self, _: discord.Button, interaction: discord.Interaction ) -> None: - logger.debug('"Verify" button pressed. %s', interaction) await interaction.response.send_modal(MakeMemberModalActual()) @@ -368,9 +372,9 @@ async def _open_make_new_member_modal( button_callback_channel: discord.TextChannel | discord.DMChannel, ) -> None: await message_sender_component.send( - content="would you like to open the make member modal", + content="would you like to open the make member modal", view=OpenMemberVerifyModalView(), - ephemeral=False + ephemeral=False, ) button_interaction: discord.Interaction = await self.bot.wait_for( @@ -385,7 +389,6 @@ async def _open_make_new_member_modal( ) if button_interaction.data["custom_id"] == "verify_new_member": # type: ignore[index, typeddict-item] - await button_interaction.message.reply( content="you have opened this modal once before", ) @@ -399,21 +402,19 @@ async def _open_make_new_member_modal( "prints a message with a button that allows users to open the make member modal, " ), ) - @CommandChecks.check_interaction_user_has_committee_role @CommandChecks.check_interaction_user_in_main_guild async def make_member_modal( - self, - ctx: "TeXBotApplicationContext", + self, + ctx: "TeXBotApplicationContext", ) -> None: # type: ignore[misc] - """ Definition & callback response of the "make-member-modal" command. The "make-member-modal" command prints a message with a button that allows users to open the make member modal """ - + await self._open_make_new_member_modal( message_sender_component=ResponseMessageSender(ctx), interaction_user=ctx.user, From c6b5e7c1a41aee008c1c34763613d743ae7ab806 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sat, 30 Aug 2025 16:00:27 +0100 Subject: [PATCH 04/39] Formatting fixes --- cogs/__init__.py | 2 +- cogs/make_member.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cogs/__init__.py b/cogs/__init__.py index 39ddac705..7eb5f5b57 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -35,7 +35,7 @@ from .invite_link import InviteLinkCommandCog from .kill import KillCommandCog from .make_applicant import MakeApplicantContextCommandsCog, MakeApplicantSlashCommandCog -from .make_member import MakeMemberCommandCog, MemberCountCommandCog, MakeMemberModalCommandCog +from .make_member import MakeMemberCommandCog, MakeMemberModalCommandCog, MemberCountCommandCog from .ping import PingCommandCog from .remind_me import ClearRemindersBacklogTaskCog, RemindMeCommandCog from .send_get_roles_reminders import SendGetRolesRemindersTaskCog diff --git a/cogs/make_member.py b/cogs/make_member.py index f82521141..d4e855383 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -10,8 +10,8 @@ import certifi import discord from bs4 import BeautifulSoup +from discord.ui import Modal, View from django.core.exceptions import ValidationError -from discord.ui import View, Modal from config import settings from db.core.models import GroupMadeMember @@ -29,8 +29,8 @@ __all__: "Sequence[str]" = ( "MakeMemberCommandCog", - "MemberCountCommandCog", "MakeMemberModalCommandCog", + "MemberCountCommandCog", ) logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -343,7 +343,7 @@ def __init__(self) -> None: self.add_item(discord.ui.InputText(label="Student ID")) - async def callback(self, interaction: discord.Interaction): + async def callback(self, interaction: discord.Interaction) -> None: embed = discord.Embed(title="Modal Results") embed.add_field(name="Short Input", value=self.children[0].value) await interaction.response.send_message(embeds=[embed]) @@ -414,7 +414,6 @@ async def make_member_modal( The "make-member-modal" command prints a message with a button that allows users to open the make member modal """ - await self._open_make_new_member_modal( message_sender_component=ResponseMessageSender(ctx), interaction_user=ctx.user, From 1e1c02bf63ee8395d1222cd914684cb3fd80120c Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sat, 30 Aug 2025 16:17:37 +0100 Subject: [PATCH 05/39] Simplify --- cogs/make_member.py | 33 ++++++--------------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index d4e855383..d4184f705 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -17,7 +17,6 @@ from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError from utils import CommandChecks, TeXBotBaseCog -from utils.message_sender_components import ResponseMessageSender if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -25,7 +24,6 @@ from typing import Final from utils import TeXBotApplicationContext - from utils.message_sender_components import MessageSavingSenderComponent __all__: "Sequence[str]" = ( "MakeMemberCommandCog", @@ -367,35 +365,13 @@ class MakeMemberModalCommandCog(TeXBotBaseCog): async def _open_make_new_member_modal( self, - message_sender_component: "MessageSavingSenderComponent", - interaction_user: discord.User, button_callback_channel: discord.TextChannel | discord.DMChannel, ) -> None: - await message_sender_component.send( + await button_callback_channel.send( content="would you like to open the make member modal", view=OpenMemberVerifyModalView(), - ephemeral=False, ) - button_interaction: discord.Interaction = await self.bot.wait_for( - "interaction", - check=lambda interaction: ( - interaction.type == discord.InteractionType.component - and interaction.user == interaction_user - and interaction.channel == button_callback_channel - and "custom_id" in interaction.data - and interaction.data["custom_id"] in {"verify_new_member"} - ), - ) - - if button_interaction.data["custom_id"] == "verify_new_member": # type: ignore[index, typeddict-item] - await button_interaction.message.reply( - content="you have opened this modal once before", - ) - return - - raise ValueError - @discord.slash_command( # type: ignore[no-untyped-call, misc] name="make-member-modal", description=( @@ -415,7 +391,10 @@ async def make_member_modal( to open the make member modal """ await self._open_make_new_member_modal( - message_sender_component=ResponseMessageSender(ctx), - interaction_user=ctx.user, button_callback_channel=ctx.channel, ) + + await ctx.respond( + content="The make member modal has been opened in this channel.", + ephemeral=True, + ) From bebf4c41015cba9f36b980c44b51444971526ddb Mon Sep 17 00:00:00 2001 From: ewan barnett Date: Sat, 30 Aug 2025 17:59:36 +0100 Subject: [PATCH 06/39] AttributeError: 'Interaction' object has no attribute 'command'. Did you mean: 'is_command'? --- cogs/make_member.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index d4184f705..2f5c8a224 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -342,9 +342,12 @@ def __init__(self) -> None: self.add_item(discord.ui.InputText(label="Student ID")) async def callback(self, interaction: discord.Interaction) -> None: - embed = discord.Embed(title="Modal Results") - embed.add_field(name="Short Input", value=self.children[0].value) - await interaction.response.send_message(embeds=[embed]) + studentId = self.children[0].value + await is_command.command(MakeMemberCommandCog.make_member(group_member_id=studentId)) + + #embed = discord.Embed(title="Modal Results") + #embed.add_field(name="Short Input", value=self.children[0].value) + #await interaction.response.send_message(embeds=[embed]) class OpenMemberVerifyModalView(View): From ab3ee43b4c074bec9bee460a81f437c4239eb6ac Mon Sep 17 00:00:00 2001 From: ewan barnett Date: Sat, 30 Aug 2025 21:03:56 +0100 Subject: [PATCH 07/39] AttributeError: type object 'CommandChecks' has no attribute '_check_interaction_user_in_main_guild'. Did you mean: 'check_interaction_user_in_main_guild'? --- cogs/make_member.py | 50 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 2f5c8a224..cedcd7a95 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -342,13 +342,28 @@ def __init__(self) -> None: self.add_item(discord.ui.InputText(label="Student ID")) async def callback(self, interaction: discord.Interaction) -> None: - studentId = self.children[0].value - await is_command.command(MakeMemberCommandCog.make_member(group_member_id=studentId)) - - #embed = discord.Embed(title="Modal Results") - #embed.add_field(name="Short Input", value=self.children[0].value) - #await interaction.response.send_message(embeds=[embed]) - + #studentId = self.children[0].value + #await is_command.command(MakeMemberCommandCog.make_member(group_member_id=studentId)) + + embed = discord.Embed(title="Modal Results") + embed.add_field(name="Short Input", value=self.children[0].value) + await interaction.response.send_message(embeds=[embed]) + +#class WhyDoThisTwiceModalActual(Modal): +# """A discord.Modal containing a the why are you back here message.""" +# +# def __init__(self) -> None: +# super().__init__(title="You already have the Member role") +# +# self.add_item(discord.ui.InputText(label="Student ID")) +# +# async def callback(self, interaction: discord.Interaction) -> None: +# #studentId = self.children[0].value +# #await is_command.command(MakeMemberCommandCog.make_member(group_member_id=studentId)) +# +# embed = discord.Embed(title="Modal Results") +# embed.add_field(name="Short Input", value=self.children[0].value) +# await interaction.response.send_message(embeds=[embed]) class OpenMemberVerifyModalView(View): """A discord.View containing a button to open a new member verification modal.""" @@ -360,8 +375,6 @@ async def verify_new_member_button_callback( # type: ignore[misc] self, _: discord.Button, interaction: discord.Interaction ) -> None: logger.debug('"Verify" button pressed. %s', interaction) - await interaction.response.send_modal(MakeMemberModalActual()) - class MakeMemberModalCommandCog(TeXBotBaseCog): """Cog class that defines the "/make-member-modal" command and its call-back method.""" @@ -369,11 +382,30 @@ class MakeMemberModalCommandCog(TeXBotBaseCog): async def _open_make_new_member_modal( self, button_callback_channel: discord.TextChannel | discord.DMChannel, + #interaction_user: discord.User, ) -> None: await button_callback_channel.send( content="would you like to open the make member modal", view=OpenMemberVerifyModalView(), ) +# +# button_interaction: discord.Interaction = await self.bot.wait_for( +# "interaction", +# check=lambda interaction: ( +# interaction.type == discord.InteractionType.component +# and interaction.user == interaction_user +# and interaction.channel == button_callback_channel +# and "custom_id" in interaction.data +# and interaction.data["custom_id"] in {"verify_new_member"} +# ), +# ) +# if button_interaction.data["custom_id"] == "verify_new_member": # type: ignore[index, typeddict-item] +# if button_interaction.client.mem in interaction_user.roles: +# await button_interaction.response.send_modal(WhyDoThisTwiceModalActual()) +# return +# await button_interaction.response.send_modal(MakeMemberModalActual()) +# return + @discord.slash_command( # type: ignore[no-untyped-call, misc] name="make-member-modal", From c1fe0e122c3ceb46047b3a9657707331f4b5262f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 30 Aug 2025 21:27:17 +0000 Subject: [PATCH 08/39] [pre-commit.ci lite] apply automatic fixes --- cogs/make_member.py | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index cedcd7a95..90361fea7 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -342,14 +342,15 @@ def __init__(self) -> None: self.add_item(discord.ui.InputText(label="Student ID")) async def callback(self, interaction: discord.Interaction) -> None: - #studentId = self.children[0].value - #await is_command.command(MakeMemberCommandCog.make_member(group_member_id=studentId)) + # studentId = self.children[0].value + # await is_command.command(MakeMemberCommandCog.make_member(group_member_id=studentId)) embed = discord.Embed(title="Modal Results") embed.add_field(name="Short Input", value=self.children[0].value) await interaction.response.send_message(embeds=[embed]) -#class WhyDoThisTwiceModalActual(Modal): + +# class WhyDoThisTwiceModalActual(Modal): # """A discord.Modal containing a the why are you back here message.""" # # def __init__(self) -> None: @@ -365,6 +366,7 @@ async def callback(self, interaction: discord.Interaction) -> None: # embed.add_field(name="Short Input", value=self.children[0].value) # await interaction.response.send_message(embeds=[embed]) + class OpenMemberVerifyModalView(View): """A discord.View containing a button to open a new member verification modal.""" @@ -376,36 +378,37 @@ async def verify_new_member_button_callback( # type: ignore[misc] ) -> None: logger.debug('"Verify" button pressed. %s', interaction) + class MakeMemberModalCommandCog(TeXBotBaseCog): """Cog class that defines the "/make-member-modal" command and its call-back method.""" async def _open_make_new_member_modal( self, button_callback_channel: discord.TextChannel | discord.DMChannel, - #interaction_user: discord.User, + # interaction_user: discord.User, ) -> None: await button_callback_channel.send( content="would you like to open the make member modal", view=OpenMemberVerifyModalView(), ) -# -# button_interaction: discord.Interaction = await self.bot.wait_for( -# "interaction", -# check=lambda interaction: ( -# interaction.type == discord.InteractionType.component -# and interaction.user == interaction_user -# and interaction.channel == button_callback_channel -# and "custom_id" in interaction.data -# and interaction.data["custom_id"] in {"verify_new_member"} -# ), -# ) -# if button_interaction.data["custom_id"] == "verify_new_member": # type: ignore[index, typeddict-item] -# if button_interaction.client.mem in interaction_user.roles: -# await button_interaction.response.send_modal(WhyDoThisTwiceModalActual()) -# return -# await button_interaction.response.send_modal(MakeMemberModalActual()) -# return + # + # button_interaction: discord.Interaction = await self.bot.wait_for( + # "interaction", + # check=lambda interaction: ( + # interaction.type == discord.InteractionType.component + # and interaction.user == interaction_user + # and interaction.channel == button_callback_channel + # and "custom_id" in interaction.data + # and interaction.data["custom_id"] in {"verify_new_member"} + # ), + # ) + # if button_interaction.data["custom_id"] == "verify_new_member": # type: ignore[index, typeddict-item] + # if button_interaction.client.mem in interaction_user.roles: + # await button_interaction.response.send_modal(WhyDoThisTwiceModalActual()) + # return + # await button_interaction.response.send_modal(MakeMemberModalActual()) + # return @discord.slash_command( # type: ignore[no-untyped-call, misc] name="make-member-modal", From 56ec5a9f429a69481762349548f2e8fc063e71e4 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 12:23:26 +0100 Subject: [PATCH 09/39] Simplify --- cogs/make_member.py | 56 ++++++++------------------------------------- 1 file changed, 10 insertions(+), 46 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 90361fea7..936e508f6 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -3,7 +3,7 @@ import logging import re import ssl -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import aiohttp import bs4 @@ -341,30 +341,13 @@ def __init__(self) -> None: self.add_item(discord.ui.InputText(label="Student ID")) + @override async def callback(self, interaction: discord.Interaction) -> None: - # studentId = self.children[0].value - # await is_command.command(MakeMemberCommandCog.make_member(group_member_id=studentId)) - - embed = discord.Embed(title="Modal Results") - embed.add_field(name="Short Input", value=self.children[0].value) - await interaction.response.send_message(embeds=[embed]) - - -# class WhyDoThisTwiceModalActual(Modal): -# """A discord.Modal containing a the why are you back here message.""" -# -# def __init__(self) -> None: -# super().__init__(title="You already have the Member role") -# -# self.add_item(discord.ui.InputText(label="Student ID")) -# -# async def callback(self, interaction: discord.Interaction) -> None: -# #studentId = self.children[0].value -# #await is_command.command(MakeMemberCommandCog.make_member(group_member_id=studentId)) -# -# embed = discord.Embed(title="Modal Results") -# embed.add_field(name="Short Input", value=self.children[0].value) -# await interaction.response.send_message(embeds=[embed]) + await MakeMemberCommandCog.make_member( + ctx=interaction, + group_member_id=self.children[0].value, + ) + await interaction.response.send_message("Action complete.") class OpenMemberVerifyModalView(View): @@ -376,7 +359,7 @@ class OpenMemberVerifyModalView(View): async def verify_new_member_button_callback( # type: ignore[misc] self, _: discord.Button, interaction: discord.Interaction ) -> None: - logger.debug('"Verify" button pressed. %s', interaction) + await interaction.response.send_modal(MakeMemberModalActual()) class MakeMemberModalCommandCog(TeXBotBaseCog): @@ -385,31 +368,12 @@ class MakeMemberModalCommandCog(TeXBotBaseCog): async def _open_make_new_member_modal( self, button_callback_channel: discord.TextChannel | discord.DMChannel, - # interaction_user: discord.User, ) -> None: await button_callback_channel.send( content="would you like to open the make member modal", view=OpenMemberVerifyModalView(), ) - # - # button_interaction: discord.Interaction = await self.bot.wait_for( - # "interaction", - # check=lambda interaction: ( - # interaction.type == discord.InteractionType.component - # and interaction.user == interaction_user - # and interaction.channel == button_callback_channel - # and "custom_id" in interaction.data - # and interaction.data["custom_id"] in {"verify_new_member"} - # ), - # ) - # if button_interaction.data["custom_id"] == "verify_new_member": # type: ignore[index, typeddict-item] - # if button_interaction.client.mem in interaction_user.roles: - # await button_interaction.response.send_modal(WhyDoThisTwiceModalActual()) - # return - # await button_interaction.response.send_modal(MakeMemberModalActual()) - # return - @discord.slash_command( # type: ignore[no-untyped-call, misc] name="make-member-modal", description=( @@ -418,10 +382,10 @@ async def _open_make_new_member_modal( ) @CommandChecks.check_interaction_user_has_committee_role @CommandChecks.check_interaction_user_in_main_guild - async def make_member_modal( + async def make_member_modal( # type: ignore[misc] self, ctx: "TeXBotApplicationContext", - ) -> None: # type: ignore[misc] + ) -> None: """ Definition & callback response of the "make-member-modal" command. From cc35a2b1c87f108e8b13ee37db445c75cad12151 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 12:51:38 +0100 Subject: [PATCH 10/39] Refactor membership checking --- utils/msl/__init__.py | 14 +++++ utils/msl/core.py | 83 ++++++++++++++++++++++++++++++ utils/msl/memberships.py | 108 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 utils/msl/__init__.py create mode 100644 utils/msl/core.py create mode 100644 utils/msl/memberships.py diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py new file mode 100644 index 000000000..4f53df443 --- /dev/null +++ b/utils/msl/__init__.py @@ -0,0 +1,14 @@ +"""MSL utility classes & functions provided for use across the whole of the project.""" + +from typing import TYPE_CHECKING + +from .memberships import get_full_membership_list, get_membership_count, is_student_id_member + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__: "Sequence[str]" = ( + "get_full_membership_list", + "get_membership_count", + "is_student_id_member", +) diff --git a/utils/msl/core.py b/utils/msl/core.py new file mode 100644 index 000000000..827daa176 --- /dev/null +++ b/utils/msl/core.py @@ -0,0 +1,83 @@ +"""Functions to enable interaction with MSL based SU websites.""" + +import datetime as dt +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +import aiohttp +from bs4 import BeautifulSoup + +from config import settings + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from datetime import timezone + from http.cookies import Morsel + from logging import Logger + from typing import Final + +__all__: "Sequence[str]" = () + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC +TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) + +CURRENT_YEAR_START_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, + month=7, + day=1, + tzinfo=DEFAULT_TIMEZONE, +) + +CURRENT_YEAR_END_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, + month=6, + day=30, + tzinfo=DEFAULT_TIMEZONE, +) + +BASE_HEADERS: "Final[Mapping[str, str]]" = { + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Expires": "0", +} + +BASE_COOKIES: "Final[Mapping[str, str]]" = { + ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], +} + +ORGANISATION_ID: "Final[str]" = settings["ORGANISATION_ID"] + +ORGANISATION_ADMIN_URL: "Final[str]" = ( + f"https://www.guildofstudents.com/organisation/admin/{ORGANISATION_ID}/" +) + + +async def get_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: + """Get the required context headers, data and cookies to make a request to MSL.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_HEADERS, + cookies=BASE_COOKIES, + ) + data_fields: dict[str, str] = {} + cookies: dict[str, str] = {} + async with http_session, http_session.get(url=url) as field_data: + data_response = BeautifulSoup( + markup=await field_data.text(), + features="html.parser", + ) + + for field in data_response.find_all(name="input"): + if field.get("name") and field.get("value"): + data_fields[field.get("name")] = field.get("value") + + for cookie in field_data.cookies: + cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) + if cookie_morsel is not None: + cookies[cookie] = cookie_morsel.value + cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] + + return data_fields, cookies diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py new file mode 100644 index 000000000..66e39aaab --- /dev/null +++ b/utils/msl/memberships.py @@ -0,0 +1,108 @@ +"""Module for checking membership status.""" + +import logging +from typing import TYPE_CHECKING + +import aiohttp +import bs4 +from bs4 import BeautifulSoup + +from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID + +if TYPE_CHECKING: + from collections.abc import Sequence + from logging import Logger + from typing import Final + +__all__: "Sequence[str]" = ( + "get_full_membership_list", + "get_membership_count", + "is_student_id_member", +) + +MEMBERS_LIST_URL: "Final[str]" = ( + f"https://guildofstudents.com/organisation/memberlist/{ORGANISATION_ID}/?sort=groups" +) + +membership_list_cache: set[tuple[str, int]] = set() + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +async def get_full_membership_list() -> set[tuple[str, int]]: + """Get a list of tuples of student ID to names.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_HEADERS, + cookies=BASE_COOKIES, + ) + async with http_session, http_session.get(url=MEMBERS_LIST_URL) as http_response: + response_html: str = await http_response.text() + + standard_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( + markup=response_html, + features="html.parser", + ).find( + name="table", + attrs={"id": "ctl00_Main_rptGroups_ctl03_gvMemberships"}, + ) + + all_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( + markup=response_html, + features="html.parser", + ).find( + name="table", + attrs={"id": "ctl00_Main_rptGroups_ctl05_gvMemberships"}, + ) + + if standard_members_table is None or all_members_table is None: + logger.warning("One or both of the membership tables could not be found!") + logger.debug(response_html) + return set() + + if isinstance(standard_members_table, bs4.NavigableString) or isinstance( + all_members_table, bs4.NavigableString + ): + logger.warning( + "Both membership tables were found but one or both are the wrong format!", + ) + logger.debug(standard_members_table) + logger.debug(all_members_table) + return set() + + standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr") + all_members: list[bs4.Tag] = all_members_table.find_all(name="tr") + + standard_members.pop(0) + all_members.pop(0) + + member_list: set[tuple[str, int]] = { + ( + member.find_all(name="td")[0].text.strip(), + member.find_all(name="td")[ + 1 + ].text.strip(), # NOTE: This will not properly handle external members who do not have an ID... There does not appear to be a solution to this other than simply checking manually. + ) + for member in standard_members + all_members + } + + membership_list_cache.clear() + membership_list_cache.update(member_list) + + return member_list + + +async def is_student_id_member(student_id: str | int) -> bool: + """Check if the student ID is a member of the society.""" + all_ids: set[str] = {str(member[1]) for member in membership_list_cache} + + if str(student_id) in all_ids: + return True + + new_ids: set[str] = {str(member[1]) for member in await get_full_membership_list()} + + return str(student_id) in new_ids + + +async def get_membership_count() -> int: + """Return the total number of members.""" + return len(await get_full_membership_list()) From 0917ae5c962b7057ca3e9c36ec72097cd1353cd6 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:11:01 +0100 Subject: [PATCH 11/39] Refactor and Reformat --- cogs/make_member.py | 108 +++------------------------------------ utils/__init__.py | 3 ++ utils/msl/core.py | 3 +- utils/msl/memberships.py | 7 ++- 4 files changed, 17 insertions(+), 104 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 670d4d015..e043bceb0 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -4,16 +4,13 @@ import re from typing import TYPE_CHECKING -import aiohttp -import bs4 import discord -from bs4 import BeautifulSoup from django.core.exceptions import ValidationError from config import settings from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError -from utils import GLOBAL_SSL_CONTEXT, CommandChecks, TeXBotBaseCog +from utils import CommandChecks, TeXBotBaseCog, get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -154,56 +151,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - guild_member_ids: set[str] = set() - - async with ( - aiohttp.ClientSession( - headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES - ) as http_session, - http_session.get( - url=GROUPED_MEMBERS_URL, ssl=GLOBAL_SSL_CONTEXT - ) as http_response, - ): - response_html: str = await http_response.text() - - MEMBER_HTML_TABLE_IDS: Final[frozenset[str]] = frozenset( - { - "ctl00_Main_rptGroups_ctl05_gvMemberships", - "ctl00_Main_rptGroups_ctl03_gvMemberships", - "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships", - "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships", - } - ) - table_id: str - for table_id in MEMBER_HTML_TABLE_IDS: - parsed_html: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("table", {"id": table_id}) - - if parsed_html is None or isinstance(parsed_html, bs4.NavigableString): - continue - - guild_member_ids.update( - row.contents[2].text - for row in parsed_html.find_all("tr", {"class": ["msl_row", "msl_altrow"]}) - ) - - guild_member_ids.discard("") - guild_member_ids.discard("\n") - guild_member_ids.discard(" ") - - if not guild_member_ids: - await self.command_send_error( - ctx, - error_code="E1041", - logging_message=OSError( - "The guild member IDs could not be retrieved from " - "the MEMBERS_LIST_URL." - ), - ) - return - - if group_member_id not in guild_member_ids: + if not await is_student_id_member(student_id=group_member_id): await self.command_send_error( ctx, message=( @@ -276,53 +224,9 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: await ctx.defer(ephemeral=False) async with ctx.typing(): - async with ( - aiohttp.ClientSession( - headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES - ) as http_session, - http_session.get( - url=BASE_MEMBERS_URL, ssl=GLOBAL_SSL_CONTEXT - ) as http_response, - ): - response_html: str = await http_response.text() - - member_list_div: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("div", {"class": "memberlistcol"}) - - if member_list_div is None or isinstance(member_list_div, bs4.NavigableString): - await self.command_send_error( - ctx=ctx, - error_code="E1041", - logging_message=OSError( - "The member count could not be retrieved from the MEMBERS_LIST_URL." - ), - ) - return - - if "showing 100 of" in member_list_div.text.lower(): - member_count: str = member_list_div.text.split(" ")[3] - await ctx.followup.send( - content=f"{self.bot.group_full_name} has {member_count} members! :tada:" - ) - return - - member_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("table", {"id": "ctl00_ctl00_Main_AdminPageContent_gvMembers"}) - - if member_table is None or isinstance(member_table, bs4.NavigableString): - await self.command_send_error( - ctx=ctx, - error_code="E1041", - logging_message=OSError( - "The member count could not be retrieved from the MEMBERS_LIST_URL." - ), - ) - return - await ctx.followup.send( - content=f"{self.bot.group_full_name} has { - len(member_table.find_all('tr', {'class': ['msl_row', 'msl_altrow']})) - } members! :tada:" + content=( + f"{self.bot.group_full_name} has " + f"{await get_membership_count()} members! :tada:" + ) ) diff --git a/utils/__init__.py b/utils/__init__.py index 62c3eade4..6850d212e 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -9,6 +9,7 @@ from .command_checks import CommandChecks from .message_sender_components import MessageSavingSenderComponent +from .msl import get_membership_count, is_student_id_member from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog @@ -29,8 +30,10 @@ "TeXBotAutocompleteContext", "TeXBotBaseCog", "generate_invite_url", + "get_membership_count", "is_member_inducted", "is_running_in_async", + "is_student_id_member", ) if TYPE_CHECKING: diff --git a/utils/msl/core.py b/utils/msl/core.py index 827daa176..a6c528e81 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -9,6 +9,7 @@ from bs4 import BeautifulSoup from config import settings +from utils import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -64,7 +65,7 @@ async def get_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: ) data_fields: dict[str, str] = {} cookies: dict[str, str] = {} - async with http_session, http_session.get(url=url) as field_data: + async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: data_response = BeautifulSoup( markup=await field_data.text(), features="html.parser", diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 66e39aaab..69df00df7 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,6 +7,8 @@ import bs4 from bs4 import BeautifulSoup +from utils import GLOBAL_SSL_CONTEXT + from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID if TYPE_CHECKING: @@ -35,7 +37,10 @@ async def get_full_membership_list() -> set[tuple[str, int]]: headers=BASE_HEADERS, cookies=BASE_COOKIES, ) - async with http_session, http_session.get(url=MEMBERS_LIST_URL) as http_response: + async with ( + http_session, + http_session.get(url=MEMBERS_LIST_URL, ssl=GLOBAL_SSL_CONTEXT) as http_response, + ): response_html: str = await http_response.text() standard_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( From 2270e9cfb363a8767c7c45f6f22215319ac52667 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:14:52 +0100 Subject: [PATCH 12/39] Fix import error --- tests/test_utils.py | 70 ++------------------------------------------- 1 file changed, 2 insertions(+), 68 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 7d27e4aee..cdbba412d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,7 +4,7 @@ import re from typing import TYPE_CHECKING -import utils +from utils import generate_invite_url if TYPE_CHECKING: from collections.abc import Sequence @@ -12,72 +12,6 @@ __all__: "Sequence[str]" = () -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 -# class TestPlotBarChart: -# """Test case to unit-test the plot_bar_chart function.""" -# -# def test_bar_chart_generates(self) -> None: -# """Test that the bar chart generates successfully when valid arguments are passed.""" # noqa: ERA001, E501, W505 -# FILENAME: Final[str] = "output_chart.png" # noqa: ERA001 -# DESCRIPTION: Final[str] = "Bar chart of the counted value of different roles." # noqa: ERA001, E501, W505 -# -# bar_chart_image: discord.File = plot_bar_chart( -# data={"role1": 5, "role2": 7}, # noqa: ERA001 -# x_label="Role Name", # noqa: ERA001 -# y_label="Counted value", # noqa: ERA001 -# title="Counted Value Of Each Role", # noqa: ERA001 -# filename=FILENAME, # noqa: ERA001 -# description=DESCRIPTION, # noqa: ERA001 -# extra_text="This is extra text" # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# -# assert bar_chart_image.filename == FILENAME # noqa: ERA001 -# assert bar_chart_image.description == DESCRIPTION # noqa: ERA001 -# assert bool(bar_chart_image.fp.read()) is True # noqa: ERA001 - - -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 -# class TestAmountOfTimeFormatter: -# """Test case to unit-test the amount_of_time_formatter function.""" -# -# @pytest.mark.parametrize( -# "time_value", -# (1, 1.0, 0.999999, 1.000001) # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# def test_format_unit_value(self, time_value: float) -> None: -# """Test that a value of one only includes the time_scale.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# formatted_amount_of_time: str = amount_of_time_formatter(time_value, TIME_SCALE) # noqa: ERA001, E501, W505 -# -# assert formatted_amount_of_time == TIME_SCALE # noqa: ERA001 -# assert not formatted_amount_of_time.endswith("s") # noqa: ERA001 -# -# @pytest.mark.parametrize( -# "time_value", -# (*range(2, 21), 2.00, 0, 0.0, 25.0, -0, -0.0, -25.0) # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# def test_format_integer_value(self, time_value: float) -> None: -# """Test that an integer value includes the value and time_scale pluralized.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# assert amount_of_time_formatter( -# time_value, -# TIME_SCALE -# ) == f"{int(time_value)} {TIME_SCALE}s" -# -# @pytest.mark.parametrize("time_value", (3.14159, 0.005, 25.0333333)) -# def test_format_float_value(self, time_value: float) -> None: -# """Test that a float value includes the rounded value and time_scale pluralized.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# assert amount_of_time_formatter( -# time_value, -# TIME_SCALE -# ) == f"{time_value:.2f} {TIME_SCALE}s" - class TestGenerateInviteURL: """Test case to unit-test the generate_invite_url utility function.""" @@ -92,7 +26,7 @@ def test_url_generates() -> None: 10000000000000000, 99999999999999999999 ) - invite_url: str = utils.generate_invite_url( + invite_url: str = generate_invite_url( DISCORD_BOT_APPLICATION_ID, DISCORD_MAIN_GUILD_ID ) From fdda120eba038e46f17d98ddc41df94c6ea6fb19 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:25:10 +0100 Subject: [PATCH 13/39] Bit of a mess --- utils/__init__.py | 9 +-------- utils/msl/__init__.py | 8 ++++++++ utils/msl/core.py | 2 +- utils/msl/memberships.py | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/utils/__init__.py b/utils/__init__.py index 6850d212e..d2c7ecbdc 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,15 +1,13 @@ """Utility classes & functions provided for use across the whole of the project.""" import asyncio -import ssl from typing import TYPE_CHECKING -import certifi import discord from .command_checks import CommandChecks from .message_sender_components import MessageSavingSenderComponent -from .msl import get_membership_count, is_student_id_member +from .msl import get_membership_count, is_student_id_member, GLOBAL_SSL_CONTEXT from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog @@ -17,7 +15,6 @@ if TYPE_CHECKING: from collections.abc import Sequence - from typing import Final __all__: "Sequence[str]" = ( "GLOBAL_SSL_CONTEXT", @@ -46,10 +43,6 @@ | None ) -GLOBAL_SSL_CONTEXT: "Final[ssl.SSLContext]" = ssl.create_default_context( - cafile=certifi.where() -) - def generate_invite_url(discord_bot_application_id: int, discord_guild_id: int) -> str: """Execute the logic that this util function provides.""" diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 4f53df443..495a6133a 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -1,14 +1,22 @@ """MSL utility classes & functions provided for use across the whole of the project.""" +import certifi +import certifi +import ssl + from typing import TYPE_CHECKING +GLOBAL_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) + from .memberships import get_full_membership_list, get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Sequence + from typing import Final __all__: "Sequence[str]" = ( "get_full_membership_list", "get_membership_count", "is_student_id_member", + "GLOBAL_SSL_CONTEXT", ) diff --git a/utils/msl/core.py b/utils/msl/core.py index a6c528e81..09f84d930 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -9,7 +9,7 @@ from bs4 import BeautifulSoup from config import settings -from utils import GLOBAL_SSL_CONTEXT +from . import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 69df00df7..2e7f8d64c 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,10 +7,10 @@ import bs4 from bs4 import BeautifulSoup -from utils import GLOBAL_SSL_CONTEXT - from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID +from . import GLOBAL_SSL_CONTEXT + if TYPE_CHECKING: from collections.abc import Sequence from logging import Logger From 1ed9d9f28b8988e3b7b21ec12e7363f0daac6d35 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:26:57 +0100 Subject: [PATCH 14/39] Formatting --- utils/__init__.py | 2 +- utils/msl/__init__.py | 10 ++++------ utils/msl/core.py | 1 + utils/msl/memberships.py | 3 +-- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/utils/__init__.py b/utils/__init__.py index d2c7ecbdc..d342ebbd2 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -7,7 +7,7 @@ from .command_checks import CommandChecks from .message_sender_components import MessageSavingSenderComponent -from .msl import get_membership_count, is_student_id_member, GLOBAL_SSL_CONTEXT +from .msl import GLOBAL_SSL_CONTEXT, get_membership_count, is_student_id_member from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 495a6133a..610465b33 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -1,22 +1,20 @@ """MSL utility classes & functions provided for use across the whole of the project.""" -import certifi -import certifi import ssl - from typing import TYPE_CHECKING -GLOBAL_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where()) +import certifi + +GLOBAL_SSL_CONTEXT: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) from .memberships import get_full_membership_list, get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Sequence - from typing import Final __all__: "Sequence[str]" = ( + "GLOBAL_SSL_CONTEXT", "get_full_membership_list", "get_membership_count", "is_student_id_member", - "GLOBAL_SSL_CONTEXT", ) diff --git a/utils/msl/core.py b/utils/msl/core.py index 09f84d930..7dbc014d9 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -9,6 +9,7 @@ from bs4 import BeautifulSoup from config import settings + from . import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 2e7f8d64c..6f55f9e7e 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,9 +7,8 @@ import bs4 from bs4 import BeautifulSoup -from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID - from . import GLOBAL_SSL_CONTEXT +from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID if TYPE_CHECKING: from collections.abc import Sequence From 797f94d96a66a9320d620d13c6556137b073e668 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:39:29 +0100 Subject: [PATCH 15/39] Fix --- cogs/make_member.py | 3 ++- utils/__init__.py | 9 +++++---- utils/msl/__init__.py | 5 ----- utils/msl/core.py | 2 +- utils/msl/memberships.py | 2 +- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index e043bceb0..e69581aec 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -10,7 +10,8 @@ from config import settings from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError -from utils import CommandChecks, TeXBotBaseCog, get_membership_count, is_student_id_member +from utils import CommandChecks, TeXBotBaseCog +from utils.msl import is_student_id_member, get_membership_count if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/utils/__init__.py b/utils/__init__.py index d342ebbd2..4cb08ccca 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -2,12 +2,12 @@ import asyncio from typing import TYPE_CHECKING - +import certifi +import ssl import discord from .command_checks import CommandChecks from .message_sender_components import MessageSavingSenderComponent -from .msl import GLOBAL_SSL_CONTEXT, get_membership_count, is_student_id_member from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog @@ -27,10 +27,8 @@ "TeXBotAutocompleteContext", "TeXBotBaseCog", "generate_invite_url", - "get_membership_count", "is_member_inducted", "is_running_in_async", - "is_student_id_member", ) if TYPE_CHECKING: @@ -44,6 +42,9 @@ ) +GLOBAL_SSL_CONTEXT: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) + + def generate_invite_url(discord_bot_application_id: int, discord_guild_id: int) -> str: """Execute the logic that this util function provides.""" return discord.utils.oauth_url( diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 610465b33..34b0d6eaa 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -1,12 +1,7 @@ """MSL utility classes & functions provided for use across the whole of the project.""" -import ssl from typing import TYPE_CHECKING -import certifi - -GLOBAL_SSL_CONTEXT: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) - from .memberships import get_full_membership_list, get_membership_count, is_student_id_member if TYPE_CHECKING: diff --git a/utils/msl/core.py b/utils/msl/core.py index 7dbc014d9..056950746 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -10,7 +10,7 @@ from config import settings -from . import GLOBAL_SSL_CONTEXT +from utils import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 6f55f9e7e..bd89fd24c 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,7 +7,7 @@ import bs4 from bs4 import BeautifulSoup -from . import GLOBAL_SSL_CONTEXT +from utils import GLOBAL_SSL_CONTEXT from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID if TYPE_CHECKING: From 2ac933da4b5fcb26c01061cd900988f66a86e839 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:40:24 +0100 Subject: [PATCH 16/39] Reformat --- cogs/make_member.py | 2 +- utils/__init__.py | 3 ++- utils/msl/core.py | 1 - utils/msl/memberships.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index e69581aec..1d1b754db 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -11,7 +11,7 @@ from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError from utils import CommandChecks, TeXBotBaseCog -from utils.msl import is_student_id_member, get_membership_count +from utils.msl import get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Mapping, Sequence diff --git a/utils/__init__.py b/utils/__init__.py index 4cb08ccca..f9991cd35 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,9 +1,10 @@ """Utility classes & functions provided for use across the whole of the project.""" import asyncio +import ssl from typing import TYPE_CHECKING + import certifi -import ssl import discord from .command_checks import CommandChecks diff --git a/utils/msl/core.py b/utils/msl/core.py index 056950746..a6c528e81 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -9,7 +9,6 @@ from bs4 import BeautifulSoup from config import settings - from utils import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index bd89fd24c..69df00df7 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -8,6 +8,7 @@ from bs4 import BeautifulSoup from utils import GLOBAL_SSL_CONTEXT + from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID if TYPE_CHECKING: From 74e57a6a781bb401acd72758a96c70f57f2e5ae8 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:43:35 +0100 Subject: [PATCH 17/39] Revert accidental change --- utils/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utils/__init__.py b/utils/__init__.py index f9991cd35..62c3eade4 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from collections.abc import Sequence + from typing import Final __all__: "Sequence[str]" = ( "GLOBAL_SSL_CONTEXT", @@ -42,8 +43,9 @@ | None ) - -GLOBAL_SSL_CONTEXT: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) +GLOBAL_SSL_CONTEXT: "Final[ssl.SSLContext]" = ssl.create_default_context( + cafile=certifi.where() +) def generate_invite_url(discord_bot_application_id: int, discord_guild_id: int) -> str: From fc6c1aa852f0aed3da3dd9da37428102299aebc5 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:44:13 +0100 Subject: [PATCH 18/39] Revert --- tests/test_utils.py | 70 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index cdbba412d..7d27e4aee 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,7 +4,7 @@ import re from typing import TYPE_CHECKING -from utils import generate_invite_url +import utils if TYPE_CHECKING: from collections.abc import Sequence @@ -12,6 +12,72 @@ __all__: "Sequence[str]" = () +# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 +# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 +# class TestPlotBarChart: +# """Test case to unit-test the plot_bar_chart function.""" +# +# def test_bar_chart_generates(self) -> None: +# """Test that the bar chart generates successfully when valid arguments are passed.""" # noqa: ERA001, E501, W505 +# FILENAME: Final[str] = "output_chart.png" # noqa: ERA001 +# DESCRIPTION: Final[str] = "Bar chart of the counted value of different roles." # noqa: ERA001, E501, W505 +# +# bar_chart_image: discord.File = plot_bar_chart( +# data={"role1": 5, "role2": 7}, # noqa: ERA001 +# x_label="Role Name", # noqa: ERA001 +# y_label="Counted value", # noqa: ERA001 +# title="Counted Value Of Each Role", # noqa: ERA001 +# filename=FILENAME, # noqa: ERA001 +# description=DESCRIPTION, # noqa: ERA001 +# extra_text="This is extra text" # noqa: ERA001 +# ) # noqa: ERA001, RUF100 +# +# assert bar_chart_image.filename == FILENAME # noqa: ERA001 +# assert bar_chart_image.description == DESCRIPTION # noqa: ERA001 +# assert bool(bar_chart_image.fp.read()) is True # noqa: ERA001 + + +# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 +# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 +# class TestAmountOfTimeFormatter: +# """Test case to unit-test the amount_of_time_formatter function.""" +# +# @pytest.mark.parametrize( +# "time_value", +# (1, 1.0, 0.999999, 1.000001) # noqa: ERA001 +# ) # noqa: ERA001, RUF100 +# def test_format_unit_value(self, time_value: float) -> None: +# """Test that a value of one only includes the time_scale.""" +# TIME_SCALE: Final[str] = "day" # noqa: ERA001 +# +# formatted_amount_of_time: str = amount_of_time_formatter(time_value, TIME_SCALE) # noqa: ERA001, E501, W505 +# +# assert formatted_amount_of_time == TIME_SCALE # noqa: ERA001 +# assert not formatted_amount_of_time.endswith("s") # noqa: ERA001 +# +# @pytest.mark.parametrize( +# "time_value", +# (*range(2, 21), 2.00, 0, 0.0, 25.0, -0, -0.0, -25.0) # noqa: ERA001 +# ) # noqa: ERA001, RUF100 +# def test_format_integer_value(self, time_value: float) -> None: +# """Test that an integer value includes the value and time_scale pluralized.""" +# TIME_SCALE: Final[str] = "day" # noqa: ERA001 +# +# assert amount_of_time_formatter( +# time_value, +# TIME_SCALE +# ) == f"{int(time_value)} {TIME_SCALE}s" +# +# @pytest.mark.parametrize("time_value", (3.14159, 0.005, 25.0333333)) +# def test_format_float_value(self, time_value: float) -> None: +# """Test that a float value includes the rounded value and time_scale pluralized.""" +# TIME_SCALE: Final[str] = "day" # noqa: ERA001 +# +# assert amount_of_time_formatter( +# time_value, +# TIME_SCALE +# ) == f"{time_value:.2f} {TIME_SCALE}s" + class TestGenerateInviteURL: """Test case to unit-test the generate_invite_url utility function.""" @@ -26,7 +92,7 @@ def test_url_generates() -> None: 10000000000000000, 99999999999999999999 ) - invite_url: str = generate_invite_url( + invite_url: str = utils.generate_invite_url( DISCORD_BOT_APPLICATION_ID, DISCORD_MAIN_GUILD_ID ) From f3248429689c3f1023e37f12b4632ece58e07f9e Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 13:56:34 +0100 Subject: [PATCH 19/39] Add logging --- utils/msl/memberships.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 69df00df7..aefe79f8b 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -48,7 +48,7 @@ async def get_full_membership_list() -> set[tuple[str, int]]: features="html.parser", ).find( name="table", - attrs={"id": "ctl00_Main_rptGroups_ctl03_gvMemberships"}, + attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships"}, ) all_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( @@ -56,7 +56,7 @@ async def get_full_membership_list() -> set[tuple[str, int]]: features="html.parser", ).find( name="table", - attrs={"id": "ctl00_Main_rptGroups_ctl05_gvMemberships"}, + attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships"}, ) if standard_members_table is None or all_members_table is None: @@ -103,6 +103,8 @@ async def is_student_id_member(student_id: str | int) -> bool: if str(student_id) in all_ids: return True + logger.debug("Student ID %s not found in cache, fetching updated list.", student_id) + new_ids: set[str] = {str(member[1]) for member in await get_full_membership_list()} return str(student_id) in new_ids From cd1899f1b023988b05b6149b0eca61a46a39c850 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 31 Aug 2025 15:16:25 +0100 Subject: [PATCH 20/39] merge from refactor-membership-query and refactor --- cogs/make_member.py | 158 ++++++++++++--------------------------- utils/msl/__init__.py | 15 ++++ utils/msl/core.py | 84 +++++++++++++++++++++ utils/msl/memberships.py | 115 ++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 109 deletions(-) create mode 100644 utils/msl/__init__.py create mode 100644 utils/msl/core.py create mode 100644 utils/msl/memberships.py diff --git a/cogs/make_member.py b/cogs/make_member.py index 936e508f6..11627b73c 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -2,29 +2,23 @@ import logging import re -import ssl from typing import TYPE_CHECKING, override -import aiohttp -import bs4 -import certifi import discord -from bs4 import BeautifulSoup from discord.ui import Modal, View from django.core.exceptions import ValidationError from config import settings from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError -from utils import CommandChecks, TeXBotBaseCog +from utils import CommandChecks, TeXBotApplicationContext, TeXBotBaseCog +from utils.msl import get_membership_count, is_student_id_member if TYPE_CHECKING: from collections.abc import Mapping, Sequence from logging import Logger from typing import Final - from utils import TeXBotApplicationContext - __all__: "Sequence[str]" = ( "MakeMemberCommandCog", "MakeMemberModalCommandCog", @@ -161,55 +155,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - guild_member_ids: set[str] = set() - - ssl_context: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) - async with ( - aiohttp.ClientSession( - headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES - ) as http_session, - http_session.get(url=GROUPED_MEMBERS_URL, ssl=ssl_context) as http_response, - ): - response_html: str = await http_response.text() - - MEMBER_HTML_TABLE_IDS: Final[frozenset[str]] = frozenset( - { - "ctl00_Main_rptGroups_ctl05_gvMemberships", - "ctl00_Main_rptGroups_ctl03_gvMemberships", - "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships", - "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships", - } - ) - table_id: str - for table_id in MEMBER_HTML_TABLE_IDS: - parsed_html: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("table", {"id": table_id}) - - if parsed_html is None or isinstance(parsed_html, bs4.NavigableString): - continue - - guild_member_ids.update( - row.contents[2].text - for row in parsed_html.find_all("tr", {"class": ["msl_row", "msl_altrow"]}) - ) - - guild_member_ids.discard("") - guild_member_ids.discard("\n") - guild_member_ids.discard(" ") - - if not guild_member_ids: - await self.command_send_error( - ctx, - error_code="E1041", - logging_message=OSError( - "The guild member IDs could not be retrieved from " - "the MEMBERS_LIST_URL." - ), - ) - return - - if group_member_id not in guild_member_ids: + if not await is_student_id_member(student_id=group_member_id): await self.command_send_error( ctx, message=( @@ -282,57 +228,15 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: await ctx.defer(ephemeral=False) async with ctx.typing(): - ssl_context: ssl.SSLContext = ssl.create_default_context(cafile=certifi.where()) - async with ( - aiohttp.ClientSession( - headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES - ) as http_session, - http_session.get(url=BASE_MEMBERS_URL, ssl=ssl_context) as http_response, - ): - response_html: str = await http_response.text() - - member_list_div: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("div", {"class": "memberlistcol"}) - - if member_list_div is None or isinstance(member_list_div, bs4.NavigableString): - await self.command_send_error( - ctx=ctx, - error_code="E1041", - logging_message=OSError( - "The member count could not be retrieved from the MEMBERS_LIST_URL." - ), - ) - return - - if "showing 100 of" in member_list_div.text.lower(): - member_count: str = member_list_div.text.split(" ")[3] - await ctx.followup.send( - content=f"{self.bot.group_full_name} has {member_count} members! :tada:" - ) - return - - member_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - response_html, "html.parser" - ).find("table", {"id": "ctl00_ctl00_Main_AdminPageContent_gvMembers"}) - - if member_table is None or isinstance(member_table, bs4.NavigableString): - await self.command_send_error( - ctx=ctx, - error_code="E1041", - logging_message=OSError( - "The member count could not be retrieved from the MEMBERS_LIST_URL." - ), - ) - return - await ctx.followup.send( - content=f"{self.bot.group_full_name} has { - len(member_table.find_all('tr', {'class': ['msl_row', 'msl_altrow']})) - } members! :tada:" + content=( + f"{self.bot.group_full_name} has " + f"{await get_membership_count()} members! :tada:" + ) ) + class MakeMemberModalActual(Modal): """A discord.Modal containing a the input box for make member user interaction.""" @@ -343,16 +247,33 @@ def __init__(self) -> None: @override async def callback(self, interaction: discord.Interaction) -> None: - await MakeMemberCommandCog.make_member( - ctx=interaction, - group_member_id=self.children[0].value, - ) - await interaction.response.send_message("Action complete.") + student_id: str | None = self.children[0].value + if not student_id: + await interaction.response.send_message( + content="Invalid Student ID.", ephemeral=True + ) + return + + if not await is_student_id_member(student_id=student_id): + await interaction.response.send_message( + content="Student ID not found.", ephemeral=True + ) + return + + if await is_student_id_member(student_id=student_id): + await MakeMemberModalCommandCog.give_member_role( + self=MakeMemberModalCommandCog(bot=interaction.client), interaction=interaction + ) + await interaction.response.send_message(content="Action complete.") + return class OpenMemberVerifyModalView(View): """A discord.View containing a button to open a new member verification modal.""" + def __init__(self) -> None: + super().__init__(timeout=None) + @discord.ui.button( label="Verify", style=discord.ButtonStyle.primary, custom_id="verify_new_member" ) @@ -365,6 +286,25 @@ async def verify_new_member_button_callback( # type: ignore[misc] class MakeMemberModalCommandCog(TeXBotBaseCog): """Cog class that defines the "/make-member-modal" command and its call-back method.""" + @TeXBotBaseCog.listener() + async def on_ready(self) -> None: + """Add OpenMemberVerifyModalView to the bot's list of permanent views.""" + self.bot.add_view(OpenMemberVerifyModalView()) + + async def give_member_role(self, interaction: discord.Interaction) -> None: + """Gives the member role to the user who interacted with the modal.""" + if not isinstance(interaction.user, discord.Member): + await self.command_send_error( + ctx=TeXBotApplicationContext(bot=interaction.client, interaction=interaction), + message="User is not a member.", + ) + return + + await interaction.user.add_roles( + await self.bot.member_role, + reason=f'{interaction.user} used TeX Bot modal: "Make Member"', + ) + async def _open_make_new_member_modal( self, button_callback_channel: discord.TextChannel | discord.DMChannel, diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py new file mode 100644 index 000000000..34b0d6eaa --- /dev/null +++ b/utils/msl/__init__.py @@ -0,0 +1,15 @@ +"""MSL utility classes & functions provided for use across the whole of the project.""" + +from typing import TYPE_CHECKING + +from .memberships import get_full_membership_list, get_membership_count, is_student_id_member + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__: "Sequence[str]" = ( + "GLOBAL_SSL_CONTEXT", + "get_full_membership_list", + "get_membership_count", + "is_student_id_member", +) diff --git a/utils/msl/core.py b/utils/msl/core.py new file mode 100644 index 000000000..a6c528e81 --- /dev/null +++ b/utils/msl/core.py @@ -0,0 +1,84 @@ +"""Functions to enable interaction with MSL based SU websites.""" + +import datetime as dt +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +import aiohttp +from bs4 import BeautifulSoup + +from config import settings +from utils import GLOBAL_SSL_CONTEXT + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from datetime import timezone + from http.cookies import Morsel + from logging import Logger + from typing import Final + +__all__: "Sequence[str]" = () + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC +TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) + +CURRENT_YEAR_START_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, + month=7, + day=1, + tzinfo=DEFAULT_TIMEZONE, +) + +CURRENT_YEAR_END_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, + month=6, + day=30, + tzinfo=DEFAULT_TIMEZONE, +) + +BASE_HEADERS: "Final[Mapping[str, str]]" = { + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Expires": "0", +} + +BASE_COOKIES: "Final[Mapping[str, str]]" = { + ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], +} + +ORGANISATION_ID: "Final[str]" = settings["ORGANISATION_ID"] + +ORGANISATION_ADMIN_URL: "Final[str]" = ( + f"https://www.guildofstudents.com/organisation/admin/{ORGANISATION_ID}/" +) + + +async def get_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: + """Get the required context headers, data and cookies to make a request to MSL.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_HEADERS, + cookies=BASE_COOKIES, + ) + data_fields: dict[str, str] = {} + cookies: dict[str, str] = {} + async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: + data_response = BeautifulSoup( + markup=await field_data.text(), + features="html.parser", + ) + + for field in data_response.find_all(name="input"): + if field.get("name") and field.get("value"): + data_fields[field.get("name")] = field.get("value") + + for cookie in field_data.cookies: + cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) + if cookie_morsel is not None: + cookies[cookie] = cookie_morsel.value + cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] + + return data_fields, cookies diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py new file mode 100644 index 000000000..aefe79f8b --- /dev/null +++ b/utils/msl/memberships.py @@ -0,0 +1,115 @@ +"""Module for checking membership status.""" + +import logging +from typing import TYPE_CHECKING + +import aiohttp +import bs4 +from bs4 import BeautifulSoup + +from utils import GLOBAL_SSL_CONTEXT + +from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID + +if TYPE_CHECKING: + from collections.abc import Sequence + from logging import Logger + from typing import Final + +__all__: "Sequence[str]" = ( + "get_full_membership_list", + "get_membership_count", + "is_student_id_member", +) + +MEMBERS_LIST_URL: "Final[str]" = ( + f"https://guildofstudents.com/organisation/memberlist/{ORGANISATION_ID}/?sort=groups" +) + +membership_list_cache: set[tuple[str, int]] = set() + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +async def get_full_membership_list() -> set[tuple[str, int]]: + """Get a list of tuples of student ID to names.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_HEADERS, + cookies=BASE_COOKIES, + ) + async with ( + http_session, + http_session.get(url=MEMBERS_LIST_URL, ssl=GLOBAL_SSL_CONTEXT) as http_response, + ): + response_html: str = await http_response.text() + + standard_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( + markup=response_html, + features="html.parser", + ).find( + name="table", + attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships"}, + ) + + all_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( + markup=response_html, + features="html.parser", + ).find( + name="table", + attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships"}, + ) + + if standard_members_table is None or all_members_table is None: + logger.warning("One or both of the membership tables could not be found!") + logger.debug(response_html) + return set() + + if isinstance(standard_members_table, bs4.NavigableString) or isinstance( + all_members_table, bs4.NavigableString + ): + logger.warning( + "Both membership tables were found but one or both are the wrong format!", + ) + logger.debug(standard_members_table) + logger.debug(all_members_table) + return set() + + standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr") + all_members: list[bs4.Tag] = all_members_table.find_all(name="tr") + + standard_members.pop(0) + all_members.pop(0) + + member_list: set[tuple[str, int]] = { + ( + member.find_all(name="td")[0].text.strip(), + member.find_all(name="td")[ + 1 + ].text.strip(), # NOTE: This will not properly handle external members who do not have an ID... There does not appear to be a solution to this other than simply checking manually. + ) + for member in standard_members + all_members + } + + membership_list_cache.clear() + membership_list_cache.update(member_list) + + return member_list + + +async def is_student_id_member(student_id: str | int) -> bool: + """Check if the student ID is a member of the society.""" + all_ids: set[str] = {str(member[1]) for member in membership_list_cache} + + if str(student_id) in all_ids: + return True + + logger.debug("Student ID %s not found in cache, fetching updated list.", student_id) + + new_ids: set[str] = {str(member[1]) for member in await get_full_membership_list()} + + return str(student_id) in new_ids + + +async def get_membership_count() -> int: + """Return the total number of members.""" + return len(await get_full_membership_list()) From 28d5036e46236401e91d395545349f33c034ca70 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 31 Aug 2025 14:17:51 +0000 Subject: [PATCH 21/39] [pre-commit.ci lite] apply automatic fixes --- cogs/make_member.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 11627b73c..f81846d3b 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -236,7 +236,6 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: ) - class MakeMemberModalActual(Modal): """A discord.Modal containing a the input box for make member user interaction.""" From 00a71219b802e3ea022fe223eeab9b66fabfbaa2 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:47:56 +0100 Subject: [PATCH 22/39] Simplify logic --- utils/msl/memberships.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index aefe79f8b..076914cfa 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -33,28 +33,20 @@ async def get_full_membership_list() -> set[tuple[str, int]]: """Get a list of tuples of student ID to names.""" - http_session: aiohttp.ClientSession = aiohttp.ClientSession( - headers=BASE_HEADERS, - cookies=BASE_COOKIES, - ) async with ( - http_session, + aiohttp.ClientSession(headers=BASE_HEADERS, cookies=BASE_COOKIES) as http_session, http_session.get(url=MEMBERS_LIST_URL, ssl=GLOBAL_SSL_CONTEXT) as http_response, ): response_html: str = await http_response.text() - standard_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - markup=response_html, - features="html.parser", - ).find( + parsed_html: BeautifulSoup = BeautifulSoup(markup=response_html, features="html.parser") + + standard_members_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( name="table", attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships"}, ) - all_members_table: bs4.Tag | bs4.NavigableString | None = BeautifulSoup( - markup=response_html, - features="html.parser", - ).find( + all_members_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( name="table", attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships"}, ) From e548fd1c587c0c6577a9a96f32cfc4fc263b3dff Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:47:58 +0100 Subject: [PATCH 23/39] Fixes --- utils/msl/__init__.py | 1 - utils/msl/core.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 34b0d6eaa..4f53df443 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -8,7 +8,6 @@ from collections.abc import Sequence __all__: "Sequence[str]" = ( - "GLOBAL_SSL_CONTEXT", "get_full_membership_list", "get_membership_count", "is_student_id_member", diff --git a/utils/msl/core.py b/utils/msl/core.py index a6c528e81..fc8b0efe9 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -26,15 +26,15 @@ DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) -CURRENT_YEAR_START_DATE: "Final[datetime]" = datetime( +CURRENT_ACADEMIC_YEAR_START_DATE: "Final[datetime]" = datetime( year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, month=7, day=1, tzinfo=DEFAULT_TIMEZONE, ) -CURRENT_YEAR_END_DATE: "Final[datetime]" = datetime( - year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, +CURRENT_ACADEMIC_YEAR_END_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year + 1 if TODAYS_DATE.month >= 7 else TODAYS_DATE.year, month=6, day=30, tzinfo=DEFAULT_TIMEZONE, From 2657358ed28c62fdf4ec93c05444374b08789e81 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:39:23 +0100 Subject: [PATCH 24/39] Fixes --- cogs/make_member.py | 33 +++++++------ utils/msl/__init__.py | 12 +++-- utils/msl/core.py | 84 ------------------------------- utils/msl/memberships.py | 103 +++++++++++++++++++++++++++------------ 4 files changed, 98 insertions(+), 134 deletions(-) delete mode 100644 utils/msl/core.py diff --git a/cogs/make_member.py b/cogs/make_member.py index 1d1b754db..a50b578fc 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -1,7 +1,6 @@ """Contains cog classes for any make_member interactions.""" import logging -import re from typing import TYPE_CHECKING import discord @@ -11,7 +10,7 @@ from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError from utils import CommandChecks, TeXBotBaseCog -from utils.msl import get_membership_count, is_student_id_member +from utils.msl import fetch_community_group_members_count, is_id_a_community_group_member if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -114,6 +113,20 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st member_role: discord.Role = await self.bot.member_role interaction_member: discord.Member = await ctx.bot.get_main_guild_member(ctx.user) + INVALID_GUILD_MEMBER_ID: Final[str] = ( + f"{group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." + ) + + try: + group_member_id_int: int = int(group_member_id) + except ValueError: + await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID) + return + + if group_member_id_int < 1000000 or group_member_id_int > 99999999: + await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID) + return + await ctx.defer(ephemeral=True) async with ctx.typing(): if member_role in interaction_member.roles: @@ -126,19 +139,9 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - if not re.fullmatch(r"\A\d{7}\Z", group_member_id): - await self.command_send_error( - ctx, - message=( - f"{group_member_id!r} is not a valid " - f"{self.bot.group_member_id_type} ID." - ), - ) - return - if await GroupMadeMember.objects.filter( hashed_group_member_id=GroupMadeMember.hash_group_member_id( - group_member_id, self.bot.group_member_id_type + group_member_id_int, self.bot.group_member_id_type ) ).aexists(): await ctx.followup.send( @@ -152,7 +155,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - if not await is_student_id_member(student_id=group_member_id): + if not await is_id_a_community_group_member(group_member_id_int): await self.command_send_error( ctx, message=( @@ -228,6 +231,6 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: await ctx.followup.send( content=( f"{self.bot.group_full_name} has " - f"{await get_membership_count()} members! :tada:" + f"{await fetch_community_group_members_count()} members! :tada:" ) ) diff --git a/utils/msl/__init__.py b/utils/msl/__init__.py index 4f53df443..99e985e17 100644 --- a/utils/msl/__init__.py +++ b/utils/msl/__init__.py @@ -2,13 +2,17 @@ from typing import TYPE_CHECKING -from .memberships import get_full_membership_list, get_membership_count, is_student_id_member +from .memberships import ( + fetch_community_group_members_count, + fetch_community_group_members_list, + is_id_a_community_group_member, +) if TYPE_CHECKING: from collections.abc import Sequence __all__: "Sequence[str]" = ( - "get_full_membership_list", - "get_membership_count", - "is_student_id_member", + "fetch_community_group_members_count", + "fetch_community_group_members_list", + "is_id_a_community_group_member", ) diff --git a/utils/msl/core.py b/utils/msl/core.py deleted file mode 100644 index fc8b0efe9..000000000 --- a/utils/msl/core.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Functions to enable interaction with MSL based SU websites.""" - -import datetime as dt -import logging -from datetime import datetime -from typing import TYPE_CHECKING - -import aiohttp -from bs4 import BeautifulSoup - -from config import settings -from utils import GLOBAL_SSL_CONTEXT - -if TYPE_CHECKING: - from collections.abc import Mapping, Sequence - from datetime import timezone - from http.cookies import Morsel - from logging import Logger - from typing import Final - -__all__: "Sequence[str]" = () - -logger: "Final[Logger]" = logging.getLogger("TeX-Bot") - - -DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC -TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) - -CURRENT_ACADEMIC_YEAR_START_DATE: "Final[datetime]" = datetime( - year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, - month=7, - day=1, - tzinfo=DEFAULT_TIMEZONE, -) - -CURRENT_ACADEMIC_YEAR_END_DATE: "Final[datetime]" = datetime( - year=TODAYS_DATE.year + 1 if TODAYS_DATE.month >= 7 else TODAYS_DATE.year, - month=6, - day=30, - tzinfo=DEFAULT_TIMEZONE, -) - -BASE_HEADERS: "Final[Mapping[str, str]]" = { - "Cache-Control": "no-cache", - "Pragma": "no-cache", - "Expires": "0", -} - -BASE_COOKIES: "Final[Mapping[str, str]]" = { - ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], -} - -ORGANISATION_ID: "Final[str]" = settings["ORGANISATION_ID"] - -ORGANISATION_ADMIN_URL: "Final[str]" = ( - f"https://www.guildofstudents.com/organisation/admin/{ORGANISATION_ID}/" -) - - -async def get_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: - """Get the required context headers, data and cookies to make a request to MSL.""" - http_session: aiohttp.ClientSession = aiohttp.ClientSession( - headers=BASE_HEADERS, - cookies=BASE_COOKIES, - ) - data_fields: dict[str, str] = {} - cookies: dict[str, str] = {} - async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: - data_response = BeautifulSoup( - markup=await field_data.text(), - features="html.parser", - ) - - for field in data_response.find_all(name="input"): - if field.get("name") and field.get("value"): - data_fields[field.get("name")] = field.get("value") - - for cookie in field_data.cookies: - cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) - if cookie_morsel is not None: - cookies[cookie] = cookie_morsel.value - cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] - - return data_fields, cookies diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 076914cfa..5b8f8168f 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -7,34 +7,80 @@ import bs4 from bs4 import BeautifulSoup +from config import settings from utils import GLOBAL_SSL_CONTEXT -from .core import BASE_COOKIES, BASE_HEADERS, ORGANISATION_ID - if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence + from http.cookies import Morsel from logging import Logger from typing import Final + __all__: "Sequence[str]" = ( - "get_full_membership_list", - "get_membership_count", - "is_student_id_member", + "fetch_community_group_members_count", + "fetch_community_group_members_list", + "is_id_a_community_group_member", ) -MEMBERS_LIST_URL: "Final[str]" = ( - f"https://guildofstudents.com/organisation/memberlist/{ORGANISATION_ID}/?sort=groups" -) -membership_list_cache: set[tuple[str, int]] = set() +BASE_SU_PLATFORM_WEB_HEADERS: "Final[Mapping[str, str]]" = { + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Expires": "0", +} + + +BASE_SU_PLATFORM_WEB_COOKIES: "Final[Mapping[str, str]]" = { + ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], +} + + +MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}/?sort=groups" + +_membership_list_cache: "Final[dict[int, str]]" = {} # NOTE: Mapping of IDs to names + logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -async def get_full_membership_list() -> set[tuple[str, int]]: - """Get a list of tuples of student ID to names.""" +async def fetch_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: + """Get the required context headers, data and cookies to make a request to MSL.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_SU_PLATFORM_WEB_HEADERS, + cookies=BASE_SU_PLATFORM_WEB_COOKIES, + ) + data_fields: dict[str, str] = {} + cookies: dict[str, str] = {} + async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: + data_response = BeautifulSoup( + markup=await field_data.text(), + features="html.parser", + ) + + for field in data_response.find_all(name="input"): + if field.get("name") and field.get("value"): + data_fields[field.get("name")] = field.get("value") + + for cookie in field_data.cookies: + cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) + if cookie_morsel is not None: + cookies[cookie] = cookie_morsel.value + cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] + + return data_fields, cookies + + +async def fetch_community_group_members_list() -> set[tuple[str, int]]: + """ + Make a web request to fetch your community group's full membership list. + + Returns a mapping of IDs to names. + """ async with ( - aiohttp.ClientSession(headers=BASE_HEADERS, cookies=BASE_COOKIES) as http_session, + aiohttp.ClientSession( + headers=BASE_SU_PLATFORM_WEB_HEADERS, cookies=BASE_SU_PLATFORM_WEB_COOKIES + ) as http_session, http_session.get(url=MEMBERS_LIST_URL, ssl=GLOBAL_SSL_CONTEXT) as http_response, ): response_html: str = await http_response.text() @@ -66,11 +112,8 @@ async def get_full_membership_list() -> set[tuple[str, int]]: logger.debug(all_members_table) return set() - standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr") - all_members: list[bs4.Tag] = all_members_table.find_all(name="tr") - - standard_members.pop(0) - all_members.pop(0) + standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr")[1:] + all_members: list[bs4.Tag] = all_members_table.find_all(name="tr")[1:] member_list: set[tuple[str, int]] = { ( @@ -82,26 +125,24 @@ async def get_full_membership_list() -> set[tuple[str, int]]: for member in standard_members + all_members } - membership_list_cache.clear() - membership_list_cache.update(member_list) + _membership_list_cache.clear() + _membership_list_cache.update({member[1]: member[0] for member in member_list}) return member_list -async def is_student_id_member(student_id: str | int) -> bool: +async def is_id_a_community_group_member(_id: int) -> bool: """Check if the student ID is a member of the society.""" - all_ids: set[str] = {str(member[1]) for member in membership_list_cache} - - if str(student_id) in all_ids: + if _id in _membership_list_cache: return True - logger.debug("Student ID %s not found in cache, fetching updated list.", student_id) - - new_ids: set[str] = {str(member[1]) for member in await get_full_membership_list()} + logger.debug( + "ID %s not found in community group membership list cache; Fetching updated list.", _id + ) - return str(student_id) in new_ids + return _id in await fetch_community_group_members_list() # type: ignore[comparison-overlap] -async def get_membership_count() -> int: - """Return the total number of members.""" - return len(await get_full_membership_list()) +async def fetch_community_group_members_count() -> int: + """Return the total number of members in your community group.""" + return len(await fetch_community_group_members_list()) From a6d17353c77b8fdf6308a40e372fae9ab7b36c01 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:41:18 +0100 Subject: [PATCH 25/39] Docs --- utils/msl/memberships.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 5b8f8168f..887bb1d79 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -132,7 +132,7 @@ async def fetch_community_group_members_list() -> set[tuple[str, int]]: async def is_id_a_community_group_member(_id: int) -> bool: - """Check if the student ID is a member of the society.""" + """Check if the given ID is a member of your community group.""" if _id in _membership_list_cache: return True From 6f7c436a591d5ae16ed9e727dae0a6c02a715ac0 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 7 Sep 2025 16:50:38 +0100 Subject: [PATCH 26/39] do some stuff --- cogs/make_member.py | 4 ++-- utils/msl/memberships.py | 40 ++++++++++++++++++---------------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index a50b578fc..ea5377495 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -155,9 +155,9 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - if not await is_id_a_community_group_member(group_member_id_int): + if not await is_id_a_community_group_member(student_id=group_member_id_int): await self.command_send_error( - ctx, + ctx=ctx, message=( f"You must be a member of {self.bot.group_full_name} " "to use this command.\n" diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 887bb1d79..c468d11d2 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -1,5 +1,6 @@ """Module for checking membership status.""" +import contextlib import logging from typing import TYPE_CHECKING @@ -38,7 +39,8 @@ MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}/?sort=groups" -_membership_list_cache: "Final[dict[int, str]]" = {} # NOTE: Mapping of IDs to names + +_membership_list_cache: set[int] = set() logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -71,11 +73,11 @@ async def fetch_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: return data_fields, cookies -async def fetch_community_group_members_list() -> set[tuple[str, int]]: +async def fetch_community_group_members_list() -> set[int]: """ Make a web request to fetch your community group's full membership list. - Returns a mapping of IDs to names. + Returns a set of IDs. """ async with ( aiohttp.ClientSession( @@ -112,36 +114,30 @@ async def fetch_community_group_members_list() -> set[tuple[str, int]]: logger.debug(all_members_table) return set() - standard_members: list[bs4.Tag] = standard_members_table.find_all(name="tr")[1:] - all_members: list[bs4.Tag] = all_members_table.find_all(name="tr")[1:] - - member_list: set[tuple[str, int]] = { - ( - member.find_all(name="td")[0].text.strip(), - member.find_all(name="td")[ - 1 - ].text.strip(), # NOTE: This will not properly handle external members who do not have an ID... There does not appear to be a solution to this other than simply checking manually. + with contextlib.suppress(IndexError): + all_rows: list[bs4.Tag] = ( + standard_members_table.find_all(name="tr")[1:] + + all_members_table.find_all(name="tr")[1:] ) - for member in standard_members + all_members - } - _membership_list_cache.clear() - _membership_list_cache.update({member[1]: member[0] for member in member_list}) + for member in all_rows: + with contextlib.suppress(ValueError): + _membership_list_cache.add(int(member.find_all(name="td")[1].text.strip())) - return member_list + return _membership_list_cache -async def is_id_a_community_group_member(_id: int) -> bool: +async def is_id_a_community_group_member(student_id: int) -> bool: """Check if the given ID is a member of your community group.""" - if _id in _membership_list_cache: + if student_id in _membership_list_cache: return True logger.debug( - "ID %s not found in community group membership list cache; Fetching updated list.", _id + "ID %s not found in community group membership list cache; Fetching updated list.", + student_id, ) - return _id in await fetch_community_group_members_list() # type: ignore[comparison-overlap] - + return student_id in await fetch_community_group_members_list() async def fetch_community_group_members_count() -> int: """Return the total number of members in your community group.""" From 01c9e14dcd4e7e437044cb3f2c0aba6c1410d0aa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 15:55:25 +0000 Subject: [PATCH 27/39] [pre-commit.ci lite] apply automatic fixes --- utils/msl/memberships.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index c468d11d2..fa8640b78 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -139,6 +139,7 @@ async def is_id_a_community_group_member(student_id: int) -> bool: return student_id in await fetch_community_group_members_list() + async def fetch_community_group_members_count() -> int: """Return the total number of members in your community group.""" return len(await fetch_community_group_members_list()) From 72c98760b8d144249ba40e090e544d2e60cf4de6 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:27:13 +0100 Subject: [PATCH 28/39] Implement custom exception --- exceptions/__init__.py | 2 ++ exceptions/msl.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 exceptions/msl.py diff --git a/exceptions/__init__.py b/exceptions/__init__.py index 4fc98909d..dc3c44ced 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -24,6 +24,7 @@ MessagesJSONFileMissingKeyError, MessagesJSONFileValueError, ) +from .msl import MSLMembershipError from .strike import NoAuditLogsStrikeTrackingError, StrikeTrackingError if TYPE_CHECKING: @@ -44,6 +45,7 @@ "InvalidActionDescriptionError", "InvalidActionTargetError", "InvalidMessagesJSONFileError", + "MSLMembershipError", "MemberRoleDoesNotExistError", "MessagesJSONFileMissingKeyError", "MessagesJSONFileValueError", diff --git a/exceptions/msl.py b/exceptions/msl.py new file mode 100644 index 000000000..6f7680758 --- /dev/null +++ b/exceptions/msl.py @@ -0,0 +1,26 @@ +"""Custom exception classes raised when errors occur during use of MSL features.""" + +from typing import TYPE_CHECKING, override + +from typed_classproperties import classproperty + +from .base import BaseTeXBotError + +if TYPE_CHECKING: + from collections.abc import Sequence + + +__all__: "Sequence[str]" = ("MSLMembershipError",) + + +class MSLMembershipError(BaseTeXBotError, RuntimeError): + """ + Exception class to raise when any error occurs while checking MSL membership. + + If this error occurs, it is likely that MSL features will not work correctly. + """ + + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: + return "An error occurred while trying to fetch membership data from MSL." From b0f165ac6297144ae26b131fc0c7c6a9d971b060 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:31:22 +0100 Subject: [PATCH 29/39] Use the new exception --- utils/msl/memberships.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index fa8640b78..3e098bcd2 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -10,6 +10,7 @@ from config import settings from utils import GLOBAL_SSL_CONTEXT +from exceptions import MSLMembershipError if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -100,19 +101,21 @@ async def fetch_community_group_members_list() -> set[int]: ) if standard_members_table is None or all_members_table is None: - logger.warning("One or both of the membership tables could not be found!") + MEMBER_TABLE_ERROR: Final[str] = "One or both membership tables could not be found." + logger.warning(MEMBER_TABLE_ERROR) logger.debug(response_html) - return set() + raise MSLMembershipError(message=MEMBER_TABLE_ERROR) if isinstance(standard_members_table, bs4.NavigableString) or isinstance( all_members_table, bs4.NavigableString ): - logger.warning( - "Both membership tables were found but one or both are the wrong format!", + MEMBER_TABLE_FORMAT_ERROR: Final[str] = ( + "Both membership tables were found but one or both were in the wrong format." ) + logger.warning(MEMBER_TABLE_FORMAT_ERROR) logger.debug(standard_members_table) logger.debug(all_members_table) - return set() + raise MSLMembershipError(message=MEMBER_TABLE_FORMAT_ERROR) with contextlib.suppress(IndexError): all_rows: list[bs4.Tag] = ( From 2df510a33ee3f4566e585ea45da8b3fc977963eb Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:46:11 +0100 Subject: [PATCH 30/39] Refactor --- utils/msl/memberships.py | 71 +++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 3e098bcd2..a0a3bb1dc 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -9,8 +9,8 @@ from bs4 import BeautifulSoup from config import settings -from utils import GLOBAL_SSL_CONTEXT from exceptions import MSLMembershipError +from utils import GLOBAL_SSL_CONTEXT if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -90,42 +90,47 @@ async def fetch_community_group_members_list() -> set[int]: parsed_html: BeautifulSoup = BeautifulSoup(markup=response_html, features="html.parser") - standard_members_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( - name="table", - attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships"}, - ) - - all_members_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( - name="table", - attrs={"id": "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships"}, - ) - - if standard_members_table is None or all_members_table is None: - MEMBER_TABLE_ERROR: Final[str] = "One or both membership tables could not be found." - logger.warning(MEMBER_TABLE_ERROR) - logger.debug(response_html) - raise MSLMembershipError(message=MEMBER_TABLE_ERROR) + member_ids: set[int] = set() - if isinstance(standard_members_table, bs4.NavigableString) or isinstance( - all_members_table, bs4.NavigableString + table_id: str + for table_id in ( + "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl03_gvMemberships", + "ctl00_ctl00_Main_AdminPageContent_rptGroups_ctl05_gvMemberships", ): - MEMBER_TABLE_FORMAT_ERROR: Final[str] = ( - "Both membership tables were found but one or both were in the wrong format." - ) - logger.warning(MEMBER_TABLE_FORMAT_ERROR) - logger.debug(standard_members_table) - logger.debug(all_members_table) - raise MSLMembershipError(message=MEMBER_TABLE_FORMAT_ERROR) - - with contextlib.suppress(IndexError): - all_rows: list[bs4.Tag] = ( - standard_members_table.find_all(name="tr")[1:] - + all_members_table.find_all(name="tr")[1:] + filtered_table: bs4.Tag | bs4.NavigableString | None = parsed_html.find( + name="table", attrs={"id": table_id} ) - for member in all_rows: - with contextlib.suppress(ValueError): - _membership_list_cache.add(int(member.find_all(name="td")[1].text.strip())) + if filtered_table is None: + MEMBER_TABLE_ERROR: str = ( + f"Membership table with ID {table_id} could not be found." + ) + logger.warning(MEMBER_TABLE_ERROR) + logger.debug(response_html) + continue + + if isinstance(filtered_table, bs4.NavigableString): + MEMBER_TABLE_FORMAT_ERROR: str = ( + f"Membership table with ID {table_id} was found but is in the wrong format." + ) + logger.warning(MEMBER_TABLE_FORMAT_ERROR) + logger.debug(filtered_table) + raise MSLMembershipError(message=MEMBER_TABLE_FORMAT_ERROR) + + with contextlib.suppress(IndexError): + rows: list[bs4.Tag] = filtered_table.find_all(name="tr")[1:] + for member in rows: + with contextlib.suppress(ValueError): + member_ids.add(int(member.find_all(name="td")[1].text.strip())) + + if not member_ids: # NOTE: this should never be possible, because to fetch the page you need to have admin access, which requires being a member. + NO_MEMBERS_ERROR: str = "No members were found in either membership table." + logger.warning(NO_MEMBERS_ERROR) + logger.debug(response_html) + raise MSLMembershipError(message=NO_MEMBERS_ERROR) + + _membership_list_cache.clear() + _membership_list_cache.update(member_ids) return _membership_list_cache From 23082734c5e9da932de6ed7dec6342f21df563fa Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:31:57 +0100 Subject: [PATCH 31/39] Fixes from review --- cogs/make_member.py | 42 +++++++++++---------------- utils/msl/memberships.py | 62 ++++++++++++---------------------------- 2 files changed, 34 insertions(+), 70 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index ea5377495..a3c5c7d7a 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -13,16 +13,19 @@ from utils.msl import fetch_community_group_members_count, is_id_a_community_group_member if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence from logging import Logger from typing import Final from utils import TeXBotApplicationContext + __all__: "Sequence[str]" = ("MakeMemberCommandCog", "MemberCountCommandCog") + logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + _GROUP_MEMBER_ID_ARGUMENT_DESCRIPTIVE_NAME: "Final[str]" = f"""{ "Student" if ( @@ -46,21 +49,6 @@ _GROUP_MEMBER_ID_ARGUMENT_DESCRIPTIVE_NAME.lower().replace(" ", "") ) -REQUEST_HEADERS: "Final[Mapping[str, str]]" = { - "Cache-Control": "no-cache", - "Pragma": "no-cache", - "Expires": "0", -} - -REQUEST_COOKIES: "Final[Mapping[str, str]]" = { - ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"] -} - -BASE_MEMBERS_URL: "Final[str]" = ( - f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}" -) -GROUPED_MEMBERS_URL: "Final[str]" = f"{BASE_MEMBERS_URL}/?sort=groups" - class MakeMemberCommandCog(TeXBotBaseCog): """Cog class that defines the "/make-member" command and its call-back method.""" @@ -101,7 +89,9 @@ class MakeMemberCommandCog(TeXBotBaseCog): parameter_name="group_member_id", ) @CommandChecks.check_interaction_user_in_main_guild - async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: str) -> None: # type: ignore[misc] + async def make_member( # type: ignore[misc] + self, ctx: "TeXBotApplicationContext", raw_group_member_id: str + ) -> None: """ Definition & callback response of the "make_member" command. @@ -113,18 +103,18 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st member_role: discord.Role = await self.bot.member_role interaction_member: discord.Member = await ctx.bot.get_main_guild_member(ctx.user) - INVALID_GUILD_MEMBER_ID: Final[str] = ( - f"{group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." + INVALID_GUILD_MEMBER_ID_MESSAGE: Final[str] = ( + f"{raw_group_member_id!r} is not a valid {self.bot.group_member_id_type} ID." ) try: - group_member_id_int: int = int(group_member_id) + group_member_id: int = int(raw_group_member_id) except ValueError: - await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID) + await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) return - if group_member_id_int < 1000000 or group_member_id_int > 99999999: - await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID) + if group_member_id < 1000000 or group_member_id > 99999999: + await self.command_send_error(ctx=ctx, message=INVALID_GUILD_MEMBER_ID_MESSAGE) return await ctx.defer(ephemeral=True) @@ -141,7 +131,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st if await GroupMadeMember.objects.filter( hashed_group_member_id=GroupMadeMember.hash_group_member_id( - group_member_id_int, self.bot.group_member_id_type + group_member_id, self.bot.group_member_id_type ) ).aexists(): await ctx.followup.send( @@ -155,7 +145,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) return - if not await is_id_a_community_group_member(student_id=group_member_id_int): + if not await is_id_a_community_group_member(member_id=group_member_id): await self.command_send_error( ctx=ctx, message=( @@ -174,7 +164,7 @@ async def make_member(self, ctx: "TeXBotApplicationContext", group_member_id: st ) try: - await GroupMadeMember.objects.acreate(group_member_id=group_member_id) # type: ignore[misc] + await GroupMadeMember.objects.acreate(group_member_id=raw_group_member_id) # type: ignore[misc] except ValidationError as create_group_made_member_error: error_is_already_exists: bool = ( "hashed_group_member_id" in create_group_made_member_error.message_dict diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index a0a3bb1dc..bbe5703d4 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence - from http.cookies import Morsel from logging import Logger from typing import Final @@ -47,33 +46,6 @@ logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -async def fetch_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: - """Get the required context headers, data and cookies to make a request to MSL.""" - http_session: aiohttp.ClientSession = aiohttp.ClientSession( - headers=BASE_SU_PLATFORM_WEB_HEADERS, - cookies=BASE_SU_PLATFORM_WEB_COOKIES, - ) - data_fields: dict[str, str] = {} - cookies: dict[str, str] = {} - async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: - data_response = BeautifulSoup( - markup=await field_data.text(), - features="html.parser", - ) - - for field in data_response.find_all(name="input"): - if field.get("name") and field.get("value"): - data_fields[field.get("name")] = field.get("value") - - for cookie in field_data.cookies: - cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) - if cookie_morsel is not None: - cookies[cookie] = cookie_morsel.value - cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] - - return data_fields, cookies - - async def fetch_community_group_members_list() -> set[int]: """ Make a web request to fetch your community group's full membership list. @@ -102,32 +74,34 @@ async def fetch_community_group_members_list() -> set[int]: ) if filtered_table is None: - MEMBER_TABLE_ERROR: str = ( - f"Membership table with ID {table_id} could not be found." - ) - logger.warning(MEMBER_TABLE_ERROR) + logger.warning("Membership table with ID %s could not be found.", table_id) logger.debug(response_html) continue if isinstance(filtered_table, bs4.NavigableString): - MEMBER_TABLE_FORMAT_ERROR: str = ( + INVALID_MEMBER_TABLE_FORMAT_MESSAGE: str = ( f"Membership table with ID {table_id} was found but is in the wrong format." ) - logger.warning(MEMBER_TABLE_FORMAT_ERROR) + logger.warning(INVALID_MEMBER_TABLE_FORMAT_MESSAGE) logger.debug(filtered_table) - raise MSLMembershipError(message=MEMBER_TABLE_FORMAT_ERROR) + raise MSLMembershipError(message=INVALID_MEMBER_TABLE_FORMAT_MESSAGE) with contextlib.suppress(IndexError): rows: list[bs4.Tag] = filtered_table.find_all(name="tr")[1:] for member in rows: - with contextlib.suppress(ValueError): - member_ids.add(int(member.find_all(name="td")[1].text.strip())) + raw_id: str = member.find_all(name="td")[1].text.strip() + try: + member_ids.add(int(raw_id)) + except ValueError: + logger.warning( + "Failed to convert ID '%s' in membership table to an integer", raw_id + ) if not member_ids: # NOTE: this should never be possible, because to fetch the page you need to have admin access, which requires being a member. - NO_MEMBERS_ERROR: str = "No members were found in either membership table." - logger.warning(NO_MEMBERS_ERROR) + NO_MEMBERS_MESSAGE: Final[str] = "No members were found in either membership table." + logger.warning(NO_MEMBERS_MESSAGE) logger.debug(response_html) - raise MSLMembershipError(message=NO_MEMBERS_ERROR) + raise MSLMembershipError(message=NO_MEMBERS_MESSAGE) _membership_list_cache.clear() _membership_list_cache.update(member_ids) @@ -135,17 +109,17 @@ async def fetch_community_group_members_list() -> set[int]: return _membership_list_cache -async def is_id_a_community_group_member(student_id: int) -> bool: +async def is_id_a_community_group_member(member_id: int) -> bool: """Check if the given ID is a member of your community group.""" - if student_id in _membership_list_cache: + if member_id in _membership_list_cache: return True logger.debug( "ID %s not found in community group membership list cache; Fetching updated list.", - student_id, + member_id, ) - return student_id in await fetch_community_group_members_list() + return member_id in await fetch_community_group_members_list() async def fetch_community_group_members_count() -> int: From 07f8b8e6ae2c94b8669014eef8181aeab9e43614 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:57:18 +0100 Subject: [PATCH 32/39] Fix spaces --- utils/msl/memberships.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index bbe5703d4..5402fd2a3 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -31,15 +31,12 @@ "Expires": "0", } - BASE_SU_PLATFORM_WEB_COOKIES: "Final[Mapping[str, str]]" = { ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], } - MEMBERS_LIST_URL: "Final[str]" = f"https://guildofstudents.com/organisation/memberlist/{settings['ORGANISATION_ID']}/?sort=groups" - _membership_list_cache: set[int] = set() From 0e530bacebf16af486302922a42eb29b24e107cb Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:58:14 +0100 Subject: [PATCH 33/39] Move logger up --- utils/msl/memberships.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/msl/memberships.py b/utils/msl/memberships.py index 5402fd2a3..3a97360e0 100644 --- a/utils/msl/memberships.py +++ b/utils/msl/memberships.py @@ -25,6 +25,9 @@ ) +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + BASE_SU_PLATFORM_WEB_HEADERS: "Final[Mapping[str, str]]" = { "Cache-Control": "no-cache", "Pragma": "no-cache", @@ -40,9 +43,6 @@ _membership_list_cache: set[int] = set() -logger: "Final[Logger]" = logging.getLogger("TeX-Bot") - - async def fetch_community_group_members_list() -> set[int]: """ Make a web request to fetch your community group's full membership list. From 5479886acb29c3f37d95a5237922183200278e45 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:30:25 +0100 Subject: [PATCH 34/39] fix merge errors --- cogs/make_member.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 1df8c59fc..f1c80240b 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -1,9 +1,7 @@ """Contains cog classes for any make_member interactions.""" import logging -import re from typing import TYPE_CHECKING, override -from typing import TYPE_CHECKING import discord from discord.ui import Modal, View @@ -12,10 +10,11 @@ from config import settings from db.core.models import GroupMadeMember from exceptions import ApplicantRoleDoesNotExistError, GuestRoleDoesNotExistError -from utils import CommandChecks, TeXBotBaseCog -from utils.msl import fetch_community_group_members_count, is_id_a_community_group_member from utils import CommandChecks, TeXBotApplicationContext, TeXBotBaseCog -from utils.msl import get_membership_count, is_student_id_member +from utils.msl import ( + fetch_community_group_members_count, + is_id_a_community_group_member, +) if TYPE_CHECKING: from collections.abc import Sequence @@ -28,7 +27,6 @@ "MemberCountCommandCog", ) -__all__: "Sequence[str]" = ("MakeMemberCommandCog", "MemberCountCommandCog") logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -230,30 +228,41 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: f"{self.bot.group_full_name} has " f"{await fetch_community_group_members_count()} members! :tada:" ) + ) + + class MakeMemberModalActual(Modal): """A discord.Modal containing a the input box for make member user interaction.""" - @override - super().__init__(title="Make Member Modal") + @override def __init__(self) -> None: - - + super().__init__(title="Make Member Modal") self.add_item(discord.ui.InputText(label="Student ID")) + + @override async def callback(self, interaction: discord.Interaction) -> None: - student_id: str | None = self.children[0].value - if not student_id: - content="Invalid Student ID.", ephemeral=True + raw_student_id: str | None = self.children[0].value + if not raw_student_id: await interaction.response.send_message( + content="Invalid Student ID.", ephemeral=True ) + return + try: + student_id: int = int(raw_student_id) + except ValueError: + await interaction.response.send_message( + content="Student ID must be a number.", ephemeral=True + ) return - if not await is_student_id_member(student_id=student_id): + + if not await is_id_a_community_group_member(member_id=student_id): await interaction.response.send_message( content="Student ID not found.", ephemeral=True ) return - if await is_student_id_member(student_id=student_id): + if await is_id_a_community_group_member(member_id=student_id): await MakeMemberModalCommandCog.give_member_role( self=MakeMemberModalCommandCog(bot=interaction.client), interaction=interaction ) @@ -285,7 +294,7 @@ async def on_ready(self) -> None: self.bot.add_view(OpenMemberVerifyModalView()) async def give_member_role(self, interaction: discord.Interaction) -> None: - """Gives the member role to the user who interacted with the modal.""" + """Give the member role to the user who interacted with the modal.""" if not isinstance(interaction.user, discord.Member): await self.command_send_error( ctx=TeXBotApplicationContext(bot=interaction.client, interaction=interaction), From c8a4c64242322c993bdcbeddc437034edcfb6bd4 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:48:37 +0100 Subject: [PATCH 35/39] Add functionality --- cogs/make_member.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index f1c80240b..3f83fb1d1 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -237,7 +237,15 @@ class MakeMemberModalActual(Modal): @override def __init__(self) -> None: super().__init__(title="Make Member Modal") - self.add_item(discord.ui.InputText(label="Student ID")) + self.add_item( + discord.ui.InputText( + label="Student ID", + min_length=7, + max_length=7, + required=True, + placeholder="1234567", + ) + ) @override async def callback(self, interaction: discord.Interaction) -> None: @@ -256,12 +264,6 @@ async def callback(self, interaction: discord.Interaction) -> None: ) return - if not await is_id_a_community_group_member(member_id=student_id): - await interaction.response.send_message( - content="Student ID not found.", ephemeral=True - ) - return - if await is_id_a_community_group_member(member_id=student_id): await MakeMemberModalCommandCog.give_member_role( self=MakeMemberModalCommandCog(bot=interaction.client), interaction=interaction @@ -269,6 +271,10 @@ async def callback(self, interaction: discord.Interaction) -> None: await interaction.response.send_message(content="Action complete.") return + await interaction.response.send_message( + content="Student ID not found.", ephemeral=True + ) + class OpenMemberVerifyModalView(View): """A discord.View containing a button to open a new member verification modal.""" From d6605ccafef05aef915da26c72569ada92a9f8ae Mon Sep 17 00:00:00 2001 From: ewan barnett Date: Sun, 21 Sep 2025 11:17:23 +0100 Subject: [PATCH 36/39] command_checks.py error fixed for non committee member edge-case --- utils/command_checks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/command_checks.py b/utils/command_checks.py index 620c99c17..8977e40ad 100644 --- a/utils/command_checks.py +++ b/utils/command_checks.py @@ -68,9 +68,9 @@ async def _check(ctx: "TeXBotApplicationContext") -> bool: @classmethod def is_interaction_user_in_main_guild_failure(cls, check: "CheckFailure") -> bool: """Whether the check failed due to the user not being in your Discord guild.""" - return bool(check.__name__ == cls._check_interaction_user_in_main_guild.__name__) # type: ignore[attr-defined] + return bool(check.__name__ == cls.check_interaction_user_in_main_guild.__name__) # type: ignore[attr-defined] @classmethod def is_interaction_user_has_committee_role_failure(cls, check: "CheckFailure") -> bool: """Whether the check failed due to the user not having the committee role.""" - return bool(check.__name__ == cls._check_interaction_user_has_committee_role.__name__) # type: ignore[attr-defined] + return bool(check.__name__ == cls.check_interaction_user_has_committee_role.__name__) # type: ignore[attr-defined] From 2342ab5c20b61a052b5d1792aa94b9c1fa3073d2 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:17:17 +0100 Subject: [PATCH 37/39] Fixes --- cogs/make_member.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 7d65230e9..2b2f8ea75 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -1,6 +1,7 @@ """Contains cog classes for any make_member interactions.""" import logging +import re from typing import TYPE_CHECKING, override import discord @@ -28,7 +29,6 @@ ) - logger: "Final[Logger]" = logging.getLogger("TeX-Bot") From ebd7c066e27d5d0cc5e745c4d2af084ea687fcbd Mon Sep 17 00:00:00 2001 From: ewan barnett Date: Sun, 21 Sep 2025 19:27:17 +0100 Subject: [PATCH 38/39] ensured that guest role is added to verified member when they erify through the make member modal --- cogs/induct.py | 31 ++++++++++++++++++------------- cogs/make_member.py | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/cogs/induct.py b/cogs/induct.py index 04284ee9b..f4a8f4043 100644 --- a/cogs/induct.py +++ b/cogs/induct.py @@ -93,28 +93,33 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) ( f"**Congrats on joining the {self.bot.group_short_name} Discord server " f"as a {user_type}!** " - "You now have access to communicate in all the public channels.\n\n" + "\n\n" + ) + ] + + if user_type == "member": + messages_to_send.append( + f"**Thank you for becomming a member of {self.bot.group_short_name}.**\n" + "you now have access to all public channels including the minecraft server and other member only channels.\n" + "and you now also have that shiny new Role" + ) + + if user_type != "member": + messages_to_send.append( "Some things to do to get started:\n" f"1. Check out our rules in { await self.bot.get_mention_string(self.bot.rules_channel) }\n" - f"2. Head to { - await self.bot.get_mention_string(self.bot.roles_channel) - } and click on the icons to get optional roles like pronouns and year groups\n" + f"2. Head to Channels & Roles in the Onboarding screen and click on the icons to get optional roles like pronouns, year groups and games\n" "3. Change your nickname to whatever you wish others to refer to you as " "(You can do this by right-clicking your name in the members-list " 'to the right & selecting "Edit Server Profile").' - ) - ] - - if user_type != "member": - messages_to_send.append( - f"You can also get yourself an annual membership " + "You can also get yourself an annual membership " f"to {self.bot.group_full_name} for only £5! " f"Just head to {settings['PURCHASE_MEMBERSHIP_URL']}. " - "You'll get awesome perks like a free T-shirt:shirt:, " - "access to member only events:calendar_spiral: and a cool green name on " - f"the {self.bot.group_short_name} Discord server:green_square:! " + f"You'll get awesome perks like acess to the {self.bot.group_short_name} Minecraft server :pick:, " + "access to member only events :calendar_spiral: and a cool blue Role on " + f"the {self.bot.group_short_name} Discord server :blue_square:! " f"Checkout all the perks at {settings['MEMBERSHIP_PERKS_URL']}" ) diff --git a/cogs/make_member.py b/cogs/make_member.py index 2b2f8ea75..cced2305b 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -313,6 +313,20 @@ async def give_member_role(self, interaction: discord.Interaction) -> None: await self.bot.member_role, reason=f'{interaction.user} used TeX Bot modal: "Make Member"', ) + try: + guest_role: discord.Role = await self.bot.guest_role + except GuestRoleDoesNotExistError: + logger.warning( + '"/make-member" command used but the "Guest" role does not exist. ' + 'Some user\'s may now have the "Member" role without the "Guest" role. ' + 'Use the "/ensure-members-inducted" command to fix this issue.' + ) + else: + if guest_role not in interaction.user.roles: + await interaction.user.add_roles( + guest_role, + reason=f'{interaction.user} used TeX Bot slash-command: "/make-member"', + ) async def _open_make_new_member_modal( self, From e2a3237b64ab746f890252815cd086d97b0632db Mon Sep 17 00:00:00 2001 From: ewan barnett Date: Fri, 26 Sep 2025 10:10:38 +0100 Subject: [PATCH 39/39] ensured that guest role is added to verified member when they erify through the make member modal and action complete is ephemeral --- cogs/make_member.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index cced2305b..afbabe571 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -269,7 +269,7 @@ async def callback(self, interaction: discord.Interaction) -> None: await MakeMemberModalCommandCog.give_member_role( self=MakeMemberModalCommandCog(bot=interaction.client), interaction=interaction ) - await interaction.response.send_message(content="Action complete.") + await interaction.response.send_message(content="Action complete.", ephemeral=True) return await interaction.response.send_message(