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/__init__.py b/cogs/__init__.py index beca9474..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 +from .make_member import MakeMemberCommandCog, MakeMemberModalCommandCog, MemberCountCommandCog 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/induct.py b/cogs/induct.py index 04284ee9..9106424a 100644 --- a/cogs/induct.py +++ b/cogs/induct.py @@ -93,29 +93,35 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) ( f"**Congrats on joining the {self.bot.group_short_name} Discord server " f"as a {user_type}!** " - "You now have access to communicate in all the public channels.\n\n" + "\n\n" + ) + ] + + if user_type == "member": + messages_to_send.append( + f"**Thank you for becomming a member of {self.bot.group_short_name}.**\n" + "you now have access to all public channels including the minecraft server.\n" + "and you now also have that shiny new Role" + ) + + if user_type != "member": + messages_to_send.append( "Some things to do to get started:\n" f"1. Check out our rules in { await self.bot.get_mention_string(self.bot.rules_channel) }\n" - f"2. Head to { - await self.bot.get_mention_string(self.bot.roles_channel) - } and click on the icons to get optional roles like pronouns and year groups\n" + f"2. Head to Channels & Roles in the Onboarding screen and click on the " + "icons to get optional roles like pronouns, year groups and games\n" "3. Change your nickname to whatever you wish others to refer to you as " "(You can do this by right-clicking your name in the members-list " 'to the right & selecting "Edit Server Profile").' - ) - ] - - if user_type != "member": - messages_to_send.append( - f"You can also get yourself an annual membership " + "You can also get yourself an annual membership " f"to {self.bot.group_full_name} for only £5! " f"Just head to {settings['PURCHASE_MEMBERSHIP_URL']}. " - "You'll get awesome perks like a free T-shirt:shirt:, " - "access to member only events:calendar_spiral: and a cool green name on " - f"the {self.bot.group_short_name} Discord server:green_square:! " - f"Checkout all the perks at {settings['MEMBERSHIP_PERKS_URL']}" + f"You'll get awesome perks like acess to the {self.bot.group_short_name} " + "Minecraft server :pick: , access to member only events :calendar_spiral: , " + f"and a cool blue Role on the {self.bot.group_short_name} Discord server " + f":blue_square:! Checkout all the perks at {settings['MEMBERSHIP_PERKS_URL']}" ) try: diff --git a/cogs/make_member.py b/cogs/make_member.py index ad3ace56..afbabe57 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -2,26 +2,31 @@ import logging import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import discord +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.msl import fetch_community_group_members_count, is_id_a_community_group_member +from utils import CommandChecks, TeXBotApplicationContext, TeXBotBaseCog +from utils.msl import ( + fetch_community_group_members_count, + is_id_a_community_group_member, +) if TYPE_CHECKING: from collections.abc import Sequence from logging import Logger from typing import Final - from utils import TeXBotApplicationContext - - -__all__: "Sequence[str]" = ("MakeMemberCommandCog", "MemberCountCommandCog") +__all__: "Sequence[str]" = ( + "MakeMemberCommandCog", + "MakeMemberModalCommandCog", + "MemberCountCommandCog", +) logger: "Final[Logger]" = logging.getLogger("TeX-Bot") @@ -225,3 +230,136 @@ async def member_count(self, ctx: "TeXBotApplicationContext") -> None: # type: 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 + def __init__(self) -> None: + super().__init__(title="Make Member Modal") + 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: + 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 await is_id_a_community_group_member(member_id=student_id): + await MakeMemberModalCommandCog.give_member_role( + self=MakeMemberModalCommandCog(bot=interaction.client), interaction=interaction + ) + await interaction.response.send_message(content="Action complete.", ephemeral=True) + 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.""" + + def __init__(self) -> None: + super().__init__(timeout=None) + + @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: + await interaction.response.send_modal(MakeMemberModalActual()) + + +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: + """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), + 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"', + ) + try: + guest_role: discord.Role = await self.bot.guest_role + except GuestRoleDoesNotExistError: + logger.warning( + '"/make-member" command used but the "Guest" role does not exist. ' + 'Some user\'s may now have the "Member" role without the "Guest" role. ' + 'Use the "/ensure-members-inducted" command to fix this issue.' + ) + else: + if guest_role not in interaction.user.roles: + await interaction.user.add_roles( + guest_role, + reason=f'{interaction.user} used TeX Bot slash-command: "/make-member"', + ) + + async def _open_make_new_member_modal( + self, + button_callback_channel: discord.TextChannel | discord.DMChannel, + ) -> None: + await button_callback_channel.send( + content="would you like to open the make member modal", + view=OpenMemberVerifyModalView(), + ) + + @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( # type: ignore[misc] + self, + ctx: "TeXBotApplicationContext", + ) -> None: + """ + 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( + button_callback_channel=ctx.channel, + ) + + await ctx.respond( + content="The make member modal has been opened in this channel.", + ephemeral=True, + ) 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