11"""Contains cog classes for SU platform access cookie authorisation check interactions."""
22
33import logging
4- from typing import TYPE_CHECKING
4+ from enum import Enum
5+ from typing import TYPE_CHECKING , override
56
67import aiohttp
78import bs4
89import discord
9- from bs4 import BeautifulSoup
10+ from discord . ext import tasks
1011
1112from config import settings
1213from utils import CommandChecks , TeXBotBaseCog
14+ from utils .error_capture_decorators import (
15+ capture_guild_does_not_exist_error ,
16+ )
1317
1418if TYPE_CHECKING :
15- from collections .abc import Collection , Mapping , Sequence
19+ from collections .abc import Iterable , Mapping , Sequence
20+ from collections .abc import Set as AbstractSet
1621 from logging import Logger
1722 from typing import Final
1823
19- from utils import TeXBotApplicationContext
24+ from utils import TeXBot , TeXBotApplicationContext
2025
21- __all__ : "Sequence[str]" = ("CheckSUPlatformAuthorisationCommandCog" ,)
26+ __all__ : "Sequence[str]" = (
27+ "CheckSUPlatformAuthorisationCommandCog" ,
28+ "CheckSUPlatformAuthorisationTaskCog" ,
29+ )
2230
2331logger : "Final[Logger]" = logging .getLogger ("TeX-Bot" )
2432
2937}
3038
3139REQUEST_COOKIES : "Final[Mapping[str, str]]" = {
32- ".ASPXAUTH" : settings ["SU_PLATFORM_ACCESS_COOKIE" ],
40+ ".ASPXAUTH" : settings ["SU_PLATFORM_ACCESS_COOKIE" ]
3341}
3442
35- REQUEST_URL : "Final[str]" = "https://guildofstudents.com/profile"
43+ SU_PLATFORM_PROFILE_URL : "Final[str]" = "https://guildofstudents.com/profile"
44+ SU_PLATFORM_ORGANISATION_URL : "Final[str]" = (
45+ "https://www.guildofstudents.com/organisation/admin"
46+ )
3647
3748
38- class CheckSUPlatformAuthorisationCommandCog ( TeXBotBaseCog ):
39- """Cog class that defines the "/check-su-platform-authorisation-cookie" command ."""
49+ class SUPlatformAccessCookieStatus ( Enum ):
50+ """Enum class defining the status of the SU Platform Access Cookie ."""
4051
41- @discord .slash_command ( # type: ignore[no-untyped-call, misc]
42- name = "check-su-platform-authorisation" ,
43- description = "Checks the authorisations held by the SU access token." ,
52+ INVALID = (
53+ logging .WARNING ,
54+ (
55+ "The SU platform access cookie is not associated with any MSL user, "
56+ "meaning it is invalid or expired."
57+ ),
58+ )
59+ VALID = (
60+ logging .WARNING ,
61+ (
62+ "The SU platform access cookie is associated with a valid MSL user, "
63+ "but is not an admin to any MSL organisations."
64+ ),
65+ )
66+ AUTHORISED = (
67+ logging .INFO ,
68+ (
69+ "The SU platform access cookie is associated with a valid MSL user and "
70+ "has access to at least one MSL organisation."
71+ ),
4472 )
45- @CommandChecks .check_interaction_user_has_committee_role
46- @CommandChecks .check_interaction_user_in_main_guild
47- async def check_su_platform_authorisation (self , ctx : "TeXBotApplicationContext" ) -> None : # type: ignore[misc]
48- """
49- Definition of the "check_su_platform_authorisation" command.
5073
51- The "check_su_platform_authorisation" command will retrieve the profile for the user.
52- The profile page will contain the user's name and a list of the MSL organisations
53- the user has administrative access to.
54- """
55- http_session : aiohttp .ClientSession = aiohttp .ClientSession (
56- headers = REQUEST_HEADERS , cookies = REQUEST_COOKIES
74+
75+ class CheckSUPlatformAuthorisationBaseCog (TeXBotBaseCog ):
76+ """Cog class that defines the base functionality for cookie authorisation checks."""
77+
78+ async def _fetch_url_content_with_session (self , url : str ) -> str :
79+ """Fetch the HTTP content at the given URL, using a shared aiohttp session."""
80+ async with (
81+ aiohttp .ClientSession (
82+ headers = REQUEST_HEADERS , cookies = REQUEST_COOKIES
83+ ) as http_session ,
84+ http_session .get (url ) as http_response ,
85+ ):
86+ return await http_response .text ()
87+
88+ async def get_su_platform_access_cookie_status (self ) -> SUPlatformAccessCookieStatus :
89+ """Retrieve the current validity status of the SU platform access cookie."""
90+ response_object : bs4 .BeautifulSoup = bs4 .BeautifulSoup (
91+ await self ._fetch_url_content_with_session (SU_PLATFORM_PROFILE_URL ), "html.parser"
92+ )
93+ page_title : bs4 .Tag | bs4 .NavigableString | None = response_object .find ("title" )
94+ if not page_title or "Login" in str (page_title ):
95+ logger .debug ("Token is invalid or expired." )
96+ return SUPlatformAccessCookieStatus .INVALID
97+
98+ organisation_admin_url : str = (
99+ f"{ SU_PLATFORM_ORGANISATION_URL } /{ settings ['ORGANISATION_ID' ]} "
57100 )
101+ response_html : str = await self ._fetch_url_content_with_session (organisation_admin_url )
58102
59- async with http_session , http_session . get ( REQUEST_URL ) as http_response :
60- response_html : str = await http_response . text ()
103+ if "admin tools" in response_html . lower () :
104+ return SUPlatformAccessCookieStatus . AUTHORISED
61105
62- response_object : bs4 .BeautifulSoup = BeautifulSoup (response_html , "html.parser" )
106+ if "You do not have any permissions for this organisation" in response_html .lower ():
107+ return SUPlatformAccessCookieStatus .VALID
108+
109+ logger .warning (
110+ "Unexpected response when checking SU platform access cookie authorisation."
111+ )
112+ return SUPlatformAccessCookieStatus .INVALID
113+
114+ async def get_su_platform_organisations (self ) -> "Iterable[str]" :
115+ """Retrieve the MSL organisations the current SU platform cookie has access to."""
116+ response_object : bs4 .BeautifulSoup = bs4 .BeautifulSoup (
117+ await self ._fetch_url_content_with_session (SU_PLATFORM_PROFILE_URL ), "html.parser"
118+ )
63119
64120 page_title : bs4 .Tag | bs4 .NavigableString | None = response_object .find ("title" )
65121
66122 if not page_title :
67- await self . command_send_error (
68- ctx = ctx ,
69- message = "Profile page returned no content when checking token authorisation!" ,
123+ logger . warning (
124+ "Profile page returned no content when checking "
125+ "SU platform access cookie's authorisation."
70126 )
71- return
127+ return ()
72128
73129 if "Login" in str (page_title ):
74- INVALID_COOKIE_MESSAGE : Final [ str ] = (
75- "Unable to fetch profile page because "
76- "the SU platform access cookie was not valid ."
130+ logger . warning (
131+ "Authentication redirected to login page. "
132+ "SU platform access cookie is invalid or expired ."
77133 )
78- logger .warning (INVALID_COOKIE_MESSAGE )
79- await ctx .respond (content = INVALID_COOKIE_MESSAGE )
80- return
134+ return ()
81135
82136 profile_section_html : bs4 .Tag | bs4 .NavigableString | None = response_object .find (
83137 "div" , {"id" : "profile_main" }
@@ -86,24 +140,19 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext")
86140 if profile_section_html is None :
87141 logger .warning (
88142 "Couldn't find the profile section of the user "
89- "when scraping the website's HTML!"
90- )
91- logger .debug ("Retrieved HTML: %s" , response_html )
92- await ctx .respond (
93- "Couldn't find the profile of the user! "
94- "This should never happen, please check the logs!"
143+ "when scraping the SU platform's website HTML."
95144 )
96- return
145+ logger .debug ("Retrieved HTML: %s" , response_object .text )
146+ return ()
97147
98148 user_name : bs4 .Tag | bs4 .NavigableString | int | None = profile_section_html .find ("h1" )
99149
100150 if not isinstance (user_name , bs4 .Tag ):
101- NO_PROFILE_DEBUG_MESSAGE : Final [ str ] = (
102- "Found user profile but couldn't find their name! "
151+ logger . warning (
152+ "Found user profile on the SU platform but couldn't find their name. "
103153 )
104- logger .debug (NO_PROFILE_DEBUG_MESSAGE )
105- await ctx .respond (NO_PROFILE_DEBUG_MESSAGE )
106- return
154+ logger .debug ("Retrieved HTML: %s" , response_object .text )
155+ return ()
107156
108157 parsed_html : bs4 .Tag | bs4 .NavigableString | None = response_object .find (
109158 "ul" , {"id" : "ulOrgs" }
@@ -112,36 +161,109 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext")
112161 if parsed_html is None or isinstance (parsed_html , bs4 .NavigableString ):
113162 NO_ADMIN_TABLE_MESSAGE : Final [str ] = (
114163 f"Failed to retrieve the admin table for user: { user_name .string } . "
115- "Please check you have used the correct SU platform access cookie !"
164+ "Please check you have used the correct SU platform access token !"
116165 )
117166 logger .warning (NO_ADMIN_TABLE_MESSAGE )
118- await ctx .respond (content = NO_ADMIN_TABLE_MESSAGE )
119- return
167+ return ()
120168
121- organisations : Collection [str ] = [
169+ organisations : Iterable [str ] = [
122170 list_item .get_text (strip = True ) for list_item in parsed_html .find_all ("li" )
123171 ]
124172
125- if not organisations :
126- logger .warning (
127- (
128- "Organisations list was unexpectedly empty "
129- "for the SU platform access cookie associated with %s."
130- ),
131- user_name .text ,
132- )
133- await ctx .respond (content = "Unexpectedly empty organisations error." )
134- return
135-
136173 logger .debug (
137- "The SU platform access cookie has administrator access to: %s as user %s" ,
174+ "SU platform access cookie has admin authorisation to: %s as user %s" ,
138175 organisations ,
139176 user_name .text ,
140177 )
141178
142- await ctx .respond (
143- "The SU platform access cookie has administrator access to "
144- "the following MSL Organisations as "
145- f"{ user_name .text } :\n { ',\n ' .join (organisation for organisation in organisations )} " ,
146- ephemeral = True ,
179+ return organisations
180+
181+
182+ class CheckSUPlatformAuthorisationCommandCog (CheckSUPlatformAuthorisationBaseCog ):
183+ """Cog class that defines the "/check-su-platform-authorisation" command."""
184+
185+ @discord .slash_command ( # type: ignore[no-untyped-call, misc]
186+ name = "check-su-platform-authorisation" ,
187+ description = "Checks the authorisation held by the SU platform access cookie." ,
188+ )
189+ @CommandChecks .check_interaction_user_has_committee_role
190+ @CommandChecks .check_interaction_user_in_main_guild
191+ async def check_su_platform_authorisation (self , ctx : "TeXBotApplicationContext" ) -> None : # type: ignore[misc]
192+ """
193+ Definition of the "check_su_platform_authorisation" command.
194+
195+ The "check_su_platform_authorisation" command will retrieve the profile for the user.
196+ The profile page will contain the user's name and a list of the MSL organisations
197+ the user has administrative access to.
198+ """
199+ await ctx .defer (ephemeral = True )
200+
201+ async with ctx .typing ():
202+ su_platform_access_cookie_organisations : AbstractSet [str ] = set (
203+ await self .get_su_platform_organisations ()
204+ )
205+
206+ await ctx .followup .send (
207+ content = (
208+ "No MSL organisations are available to the SU platform access cookie. "
209+ "Please check the logs for errors."
210+ if not su_platform_access_cookie_organisations
211+ else (
212+ f"SU Platform Access Cookie has access to the following "
213+ "MSL Organisations:"
214+ f"\n {
215+ ',\n ' .join (
216+ organisation
217+ for organisation in su_platform_access_cookie_organisations
218+ )
219+ } "
220+ )
221+ ),
222+ ephemeral = True ,
223+ )
224+
225+
226+ class CheckSUPlatformAuthorisationTaskCog (CheckSUPlatformAuthorisationBaseCog ):
227+ """Cog class defining a repeated task for checking SU platform access cookie."""
228+
229+ @override
230+ def __init__ (self , bot : "TeXBot" ) -> None :
231+ """Start all task managers when this cog is initialised."""
232+ if settings ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING" ]:
233+ _ = self .su_platform_access_cookie_check_task .start ()
234+
235+ super ().__init__ (bot )
236+
237+ @override
238+ def cog_unload (self ) -> None :
239+ """
240+ Unload-hook that ends all running tasks whenever the tasks cog is unloaded.
241+
242+ This may be run dynamically or when the bot closes.
243+ """
244+ self .su_platform_access_cookie_check_task .cancel ()
245+
246+ @tasks .loop (** settings ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL" ])
247+ @capture_guild_does_not_exist_error
248+ async def su_platform_access_cookie_check_task (self ) -> None :
249+ """
250+ Definition of the repeated background task that checks the SU platform access cookie.
251+
252+ The task will check if the cookie is valid and if it is, it will retrieve the
253+ MSL organisations the cookie has access to.
254+ """
255+ logger .debug ("Running SU platform access cookie check task..." )
256+
257+ su_platform_access_cookie_status : tuple [int , str ] = (
258+ await self .get_su_platform_access_cookie_status ()
259+ ).value
260+
261+ logger .log (
262+ level = su_platform_access_cookie_status [0 ],
263+ msg = su_platform_access_cookie_status [1 ],
147264 )
265+
266+ @su_platform_access_cookie_check_task .before_loop
267+ async def before_tasks (self ) -> None :
268+ """Pre-execution hook, preventing any tasks from executing before the bot is ready."""
269+ await self .bot .wait_until_ready ()
0 commit comments