diff --git a/.env.example b/.env.example deleted file mode 100644 index d69be8b3c..000000000 --- a/.env.example +++ /dev/null @@ -1,122 +0,0 @@ -# !!REQUIRED!! -# The Discord token for the bot you created (available on your bot page in the developer portal: https://discord.com/developers/applications)) -# Must be a valid Discord bot token (see https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts) -DISCORD_BOT_TOKEN=[Replace with your Discord bot token] - -# !!REQUIRED!! -# The ID of the your Discord guild -# Must be a valid Discord guild ID (see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id) -DISCORD_GUILD_ID=[Replace with the ID of the your Discord guild] - -# The webhook URL of the Discord text channel where error logs should be sent -# Error logs will always be sent to the console, this setting allows them to also be sent to a Discord log channel -# Must be a valid Discord channel webhook URL (see https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) -DISCORD_LOG_CHANNEL_WEBHOOK_URL=[Replace with your Discord log channel webhook URL] - -# The full name of your community group, do NOT use an abbreviation. -# This is substituted into many error/welcome messages sent into your Discord guild, by the bot. -# If this is not set the group-full-name will be retrieved from the name of your group's Discord guild -GROUP_NAME=[Replace with the full name of your community group (not an abbreviation)] - -# The short colloquial name of your community group, it is recommended that you set this to be an abbreviation of your group's name. -# If this is not set the group-short-name will be determined from your group's full name -GROUP_SHORT_NAME=[Replace with the short colloquial name of your community group] - -# The URL of the page where guests can purchase a full membership to join your community group -# Must be a valid URL -PURCHASE_MEMBERSHIP_URL=[Replace with your group\'s purchase-membership URL] - -# The URL of the page containing information about the perks of buying a full membership to join your community group -# Must be a valid URL -MEMBERSHIP_PERKS_URL=[Replace with your group\'s membership-perks URL] - -# The invite link URL to allow users to join your community group's Discord server -# Must be a valid URL -CUSTOM_DISCORD_INVITE_URL=[Replace with your group\'s Discord server invite link] - - -# The minimum level that logs must meet in order to be logged to the console output stream -# One of: DEBUG, INFO, WARNING, ERROR, CRITICAL -CONSOLE_LOG_LEVEL=INFO - - -# !!REQUIRED!! -# The URL to retrieve the list of IDs of people that have purchased a membership to your community group -# Ensure that all members are visible without pagination. For example, if your members-list is found on the UoB Guild of Students website, ensure the URL includes the "sort by groups" option -# Must be a valid URL -ORGANISATION_ID=[Replace with your group\'s MSL Organisation ID] - -# !!REQUIRED!! -# The cookie required for access to your Student Union's online platform. -# If your group's members-list is stored at a URL that requires authentication, this session cookie should authenticate the bot to view your group's members-list, as if it were logged in to the website as a Committee member -# This can be extracted from your web-browser, after logging in to view your members-list yourself. It will probably be listed as a cookie named `.ASPXAUTH` -SU_PLATFORM_ACCESS_COOKIE=[Replace with your .ASPXAUTH cookie] - - -# The probability that the more rare ping command response will be sent instead of the normal one -# Must be a float between & including 0 to 1 -PING_COMMAND_EASTER_EGG_PROBABILITY=0.01 - - -# The path to the messages JSON file that contains the common messages sent by the bot -# Must be a path to a JSON file that exists, that contains a JSON string that can be decoded into a Python dict object -MESSAGES_FILE_PATH=messages.json - - -# Whether introduction reminders will be sent to Discord members that are not inducted, saying that they need to send an introduction to be allowed access -# One of: Once, Interval, False -SEND_INTRODUCTION_REMINDERS=Once - -# How long to wait after a user joins your guild before sending them the first/only message remind them to send an introduction -# Is ignored if SEND_INTRODUCTION_REMINDERS=False -# Must be a string of the seconds, minutes, hours, days or weeks before the first/only reminder is sent (format: "smhdw") -# The delay must be longer than or equal to 1 day (in any allowed format) -SEND_INTRODUCTION_REMINDERS_DELAY=40h - -# The interval of time between sending out reminders to Discord members that are not inducted, saying that they need to send an introduction to be allowed access -# Is ignored if SEND_INTRODUCTION_REMINDERS=Once or SEND_INTRODUCTION_REMINDERS=False -# Must be a string of the seconds, minutes or hours between reminders (format: "smh") -SEND_INTRODUCTION_REMINDERS_INTERVAL=6h - -# Whether reminders will be sent to Discord members that have been inducted, saying that they can get opt-in roles. (This message will be only sent once per Discord member) -# Must be a boolean (True or False) -SEND_GET_ROLES_REMINDERS=True - -# How long to wait after a user is inducted before sending them the message to get some opt-in roles -# Is ignored if SEND_GET_ROLES_REMINDERS=False -# Must be a string of the seconds, minutes, hours, days or weeks before a reminder is sent (format: "smhdw") -# The delay must be longer than or equal to 1 day (in any allowed format) -SEND_GET_ROLES_REMINDERS_DELAY=40h - -# !!This is an advanced configuration variable, so is unlikely to need to be changed from its default value!! -# The interval of time between sending out reminders to Discord members that have been inducted, saying that they can get opt-in roles. (This message will be only sent once, the interval is just how often the check for new guests occurs) -# Is ignored if SEND_GET_ROLES_REMINDERS=False -# Must be a string of the seconds, minutes or hours between reminders (format: "smh") -ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL=24h - - -# The number of days to look over messages sent, to generate statistics data -# Must be a float representing the number of days to look back through -STATISTICS_DAYS=30 - -# The names of the roles to gather statistics about, to display in bar chart graphs -# Must be a comma seperated list of strings of role names -STATISTICS_ROLES=Committee,Committee-Elect,Student Rep,Member,Guest,Server Booster,Foundation Year,First Year,Second Year,Final Year,Year In Industry,Year Abroad,PGT,PGR,Alumnus/Alumna,Postdoc,Quiz Victor - - -# !!REQUIRED!! -# The URL of the your group's Discord guild moderation document -# Must be a valid URL -MODERATION_DOCUMENT_URL=[Replace with your group\'s moderation document URL] - - -# The name of the channel, that warning messages will be sent to when a committee-member manually applies a moderation action (instead of using the `/strike` command) -# Must be the name of a Discord channel in your group's Discord guild, or the value "DM" (which indicates that the messages will be sent in the committee-member's DMs) -# This can be the name of ANY Discord channel (so the offending person *will* be able to see these messages if a public channel is chosen) -MANUAL_MODERATION_WARNING_MESSAGE_LOCATION=DM - - -# The set of roles that are tied to the membership of your community group -# These roles will be removed along with the membership role upon annual handover/reset -# Must be a comma seperated list of strings of role names -MEMBERSHIP_DEPENDENT_ROLES=member-red,member-blue,member-green,member-yellow,member-purple,member-pink,member-orange,member-grey,member-black,member-white \ No newline at end of file diff --git a/cogs/__init__.py b/cogs/__init__.py index beca94749..7eb5f5b57 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -35,7 +35,7 @@ from .invite_link import InviteLinkCommandCog from .kill import KillCommandCog from .make_applicant import MakeApplicantContextCommandsCog, MakeApplicantSlashCommandCog -from .make_member import MakeMemberCommandCog, MemberCountCommandCog +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 04284ee9b..f4a8f4043 100644 --- a/cogs/induct.py +++ b/cogs/induct.py @@ -93,28 +93,33 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) ( f"**Congrats on joining the {self.bot.group_short_name} Discord server " f"as a {user_type}!** " - "You now have access to communicate in all the public channels.\n\n" + "\n\n" + ) + ] + + if user_type == "member": + messages_to_send.append( + f"**Thank you for becomming a member of {self.bot.group_short_name}.**\n" + "you now have access to all public channels including the minecraft server and other member only channels.\n" + "and you now also have that shiny new Role" + ) + + if user_type != "member": + messages_to_send.append( "Some things to do to get started:\n" f"1. Check out our rules in { await self.bot.get_mention_string(self.bot.rules_channel) }\n" - f"2. Head to { - await self.bot.get_mention_string(self.bot.roles_channel) - } and click on the icons to get optional roles like pronouns and year groups\n" + f"2. Head to Channels & Roles in the Onboarding screen and click on the icons to get optional roles like pronouns, year groups and games\n" "3. Change your nickname to whatever you wish others to refer to you as " "(You can do this by right-clicking your name in the members-list " 'to the right & selecting "Edit Server Profile").' - ) - ] - - if user_type != "member": - messages_to_send.append( - f"You can also get yourself an annual membership " + "You can also get yourself an annual membership " f"to {self.bot.group_full_name} for only £5! " f"Just head to {settings['PURCHASE_MEMBERSHIP_URL']}. " - "You'll get awesome perks like a free T-shirt:shirt:, " - "access to member only events:calendar_spiral: and a cool green name on " - f"the {self.bot.group_short_name} Discord server:green_square:! " + f"You'll get awesome perks like acess to the {self.bot.group_short_name} Minecraft server :pick:, " + "access to member only events :calendar_spiral: and a cool blue Role on " + f"the {self.bot.group_short_name} Discord server :blue_square:! " f"Checkout all the perks at {settings['MEMBERSHIP_PERKS_URL']}" ) diff --git a/cogs/make_member.py b/cogs/make_member.py index ad3ace56b..afbabe571 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 000000000..a6c528e81 --- /dev/null +++ b/utils/msl/core.py @@ -0,0 +1,84 @@ +"""Functions to enable interaction with MSL based SU websites.""" + +import datetime as dt +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +import aiohttp +from bs4 import BeautifulSoup + +from config import settings +from utils import GLOBAL_SSL_CONTEXT + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from datetime import timezone + from http.cookies import Morsel + from logging import Logger + from typing import Final + +__all__: "Sequence[str]" = () + +logger: "Final[Logger]" = logging.getLogger("TeX-Bot") + + +DEFAULT_TIMEZONE: "Final[timezone]" = dt.UTC +TODAYS_DATE: "Final[datetime]" = datetime.now(tz=DEFAULT_TIMEZONE) + +CURRENT_YEAR_START_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, + month=7, + day=1, + tzinfo=DEFAULT_TIMEZONE, +) + +CURRENT_YEAR_END_DATE: "Final[datetime]" = datetime( + year=TODAYS_DATE.year if TODAYS_DATE.month >= 7 else TODAYS_DATE.year - 1, + month=6, + day=30, + tzinfo=DEFAULT_TIMEZONE, +) + +BASE_HEADERS: "Final[Mapping[str, str]]" = { + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Expires": "0", +} + +BASE_COOKIES: "Final[Mapping[str, str]]" = { + ".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"], +} + +ORGANISATION_ID: "Final[str]" = settings["ORGANISATION_ID"] + +ORGANISATION_ADMIN_URL: "Final[str]" = ( + f"https://www.guildofstudents.com/organisation/admin/{ORGANISATION_ID}/" +) + + +async def get_msl_context(url: str) -> tuple[dict[str, str], dict[str, str]]: + """Get the required context headers, data and cookies to make a request to MSL.""" + http_session: aiohttp.ClientSession = aiohttp.ClientSession( + headers=BASE_HEADERS, + cookies=BASE_COOKIES, + ) + data_fields: dict[str, str] = {} + cookies: dict[str, str] = {} + async with http_session, http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as field_data: + data_response = BeautifulSoup( + markup=await field_data.text(), + features="html.parser", + ) + + for field in data_response.find_all(name="input"): + if field.get("name") and field.get("value"): + data_fields[field.get("name")] = field.get("value") + + for cookie in field_data.cookies: + cookie_morsel: Morsel[str] | None = field_data.cookies.get(cookie) + if cookie_morsel is not None: + cookies[cookie] = cookie_morsel.value + cookies[".ASPXAUTH"] = settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] + + return data_fields, cookies