From 4aa4acda4cdfcf03027175b116cc2fe4fd5ca2b8 Mon Sep 17 00:00:00 2001 From: ewan barnett Date: Sat, 30 Aug 2025 14:42:15 +0100 Subject: [PATCH 01/42] 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 beca9474..39ddac70 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 670d4d01..3060db3d 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/42] 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 d69be8b3..00000000 --- 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 3060db3d..e356f602 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/42] [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 e356f602..f8252114 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/42] 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 39ddac70..7eb5f5b5 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 f8252114..d4e85538 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/42] 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 d4e85538..d4184f70 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/42] 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 d4184f70..2f5c8a22 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/42] 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 2f5c8a22..cedcd7a9 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/42] [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 cedcd7a9..90361fea 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/42] 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 90361fea..936e508f 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/42] 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 00000000..4f53df44 --- /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 00000000..827daa17 --- /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 00000000..66e39aaa --- /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/42] 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 670d4d01..e043bceb 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 62c3eade..6850d212 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 827daa17..a6c528e8 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 66e39aaa..69df00df 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/42] 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 7d27e4ae..cdbba412 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/42] 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 6850d212..d2c7ecbd 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 4f53df44..495a6133 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 a6c528e8..09f84d93 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 69df00df..2e7f8d64 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/42] 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 d2c7ecbd..d342ebbd 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 495a6133..610465b3 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 09f84d93..7dbc014d 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 2e7f8d64..6f55f9e7 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/42] 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 e043bceb..e69581ae 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 d342ebbd..4cb08ccc 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 610465b3..34b0d6ea 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 7dbc014d..05695074 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 6f55f9e7..bd89fd24 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/42] 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 e69581ae..1d1b754d 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 4cb08ccc..f9991cd3 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 05695074..a6c528e8 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 bd89fd24..69df00df 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/42] 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 f9991cd3..62c3eade 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/42] 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 cdbba412..7d27e4ae 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/42] 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 69df00df..aefe79f8 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/42] 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 936e508f..11627b73 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 00000000..34b0d6ea --- /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 00000000..a6c528e8 --- /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 00000000..aefe79f8 --- /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/42] [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 11627b73..f81846d3 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/42] 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 aefe79f8..076914cf 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/42] 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 34b0d6ea..4f53df44 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 a6c528e8..fc8b0efe 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/42] 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 1d1b754d..a50b578f 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 4f53df44..99e985e1 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 fc8b0efe..00000000 --- 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 076914cf..5b8f8168 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/42] 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 5b8f8168..887bb1d7 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/42] 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 a50b578f..ea537749 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 887bb1d7..c468d11d 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/42] [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 c468d11d..fa8640b7 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/42] 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 4fc98909..dc3c44ce 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 00000000..6f768075 --- /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/42] 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 fa8640b7..3e098bcd 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/42] 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 3e098bcd..a0a3bb1d 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/42] 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 ea537749..a3c5c7d7 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 a0a3bb1d..bbe5703d 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/42] 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 bbe5703d..5402fd2a 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/42] 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 5402fd2a..3a97360e 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/42] 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 1df8c59f..f1c80240 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/42] 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 f1c80240..3f83fb1d 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/42] 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 620c99c1..8977e40a 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/42] 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 7d65230e..2b2f8ea7 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 9dfb45baf3922ae9cb75da14a93018acbdb62c6e Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:31:11 +0100 Subject: [PATCH 38/42] fuck off mypy Signed-off-by: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> --- cogs/make_member.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 2b2f8ea7..fb8f781e 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -267,7 +267,7 @@ async def callback(self, interaction: discord.Interaction) -> None: if await is_id_a_community_group_member(member_id=student_id): await MakeMemberModalCommandCog.give_member_role( - self=MakeMemberModalCommandCog(bot=interaction.client), interaction=interaction + self=MakeMemberModalCommandCog(bot=interaction.client), interaction=interaction # type: ignore[arg-type] ) await interaction.response.send_message(content="Action complete.") return @@ -304,7 +304,7 @@ async def give_member_role(self, interaction: discord.Interaction) -> None: """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), + ctx=TeXBotApplicationContext(bot=interaction.client, interaction=interaction), # type: ignore[arg-type] message="User is not a member.", ) return From ab047f6a7c6c564fd22526a6c8921b5d7240b461 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, 21 Sep 2025 15:32:03 +0000 Subject: [PATCH 39/42] [pre-commit.ci lite] apply automatic fixes --- cogs/make_member.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index fb8f781e..373d6a04 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -267,7 +267,8 @@ async def callback(self, interaction: discord.Interaction) -> None: if await is_id_a_community_group_member(member_id=student_id): await MakeMemberModalCommandCog.give_member_role( - self=MakeMemberModalCommandCog(bot=interaction.client), interaction=interaction # type: ignore[arg-type] + self=MakeMemberModalCommandCog(bot=interaction.client), + interaction=interaction, # type: ignore[arg-type] ) await interaction.response.send_message(content="Action complete.") return From e9c5df07b3685073705e620f61af3204477b54d6 Mon Sep 17 00:00:00 2001 From: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:33:53 +0100 Subject: [PATCH 40/42] fucking mypy go away Signed-off-by: Matty Widdop <18513864+MattyTheHacker@users.noreply.github.com> --- cogs/make_member.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 373d6a04..7814380a 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -267,8 +267,8 @@ async def callback(self, interaction: discord.Interaction) -> None: if await is_id_a_community_group_member(member_id=student_id): await MakeMemberModalCommandCog.give_member_role( - self=MakeMemberModalCommandCog(bot=interaction.client), - interaction=interaction, # type: ignore[arg-type] + self=MakeMemberModalCommandCog(bot=interaction.client), # type: ignore[arg-type] + interaction=interaction, ) await interaction.response.send_message(content="Action complete.") return From 427ba3b9dd66540962841b48b94807f8c5f7e501 Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:16:18 +0100 Subject: [PATCH 41/42] fix env example file --- .env.example | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..107c80a6 --- /dev/null +++ b/.env.example @@ -0,0 +1,122 @@ +# !!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 `.AspNet.SharedCookie` +SU_PLATFORM_ACCESS_COOKIE=[Replace with your .AspNet.SharedCookie 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 From cd834b49a00a3317c89f9fba6d0033d27f322dfe Mon Sep 17 00:00:00 2001 From: MattyTheHacker <18513864+MattyTheHacker@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:21:32 +0100 Subject: [PATCH 42/42] Fixes --- cogs/make_member.py | 4 ++-- utils/msl/core.py | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/cogs/make_member.py b/cogs/make_member.py index 05405475..2ff40837 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -324,7 +324,7 @@ async def _open_make_new_member_modal( view=OpenMemberVerifyModalView(), ) - @discord.slash_command( # type: ignore[no-untyped-call, misc] + @discord.slash_command( name="make-member-modal", description=( "prints a message with a button that allows users to open the make member modal, " @@ -332,7 +332,7 @@ 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( # type: ignore[misc] + async def make_member_modal( self, ctx: "TeXBotApplicationContext", ) -> None: diff --git a/utils/msl/core.py b/utils/msl/core.py index a6c528e8..040b3d23 100644 --- a/utils/msl/core.py +++ b/utils/msl/core.py @@ -1,8 +1,7 @@ """Functions to enable interaction with MSL based SU websites.""" -import datetime as dt +import datetime import logging -from datetime import datetime from typing import TYPE_CHECKING import aiohttp @@ -13,7 +12,6 @@ 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 @@ -23,17 +21,17 @@ logger: "Final[Logger]" = logging.getLogger("TeX-Bot") -DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC -TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) +DEFAULT_TIMEZONE: "Final[datetime.timezone]" = datetime.UTC +TODAYS_DATE: "Final[datetime.datetime]" = datetime.datetime.now(tz=DEFAULT_TIMEZONE) -CURRENT_YEAR_START_DATE: "Final[datetime]" = datetime( +CURRENT_YEAR_START_DATE: "Final[datetime.datetime]" = 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( +CURRENT_YEAR_END_DATE: "Final[datetime.datetime]" = datetime.datetime( year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, month=6, day=30,